Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AuthMethods + Template Haskell experiment #1918

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ import { SocialButton } from '../social/SocialButton'
{=# isAnyPasswordBasedAuthEnabled =}
import { useHistory } from 'react-router-dom'
{=/ isAnyPasswordBasedAuthEnabled =}
{=# isUsernameAndPasswordAuthEnabled =}
{=# providers.isUsernameAndPasswordAuthEnabled =}
import { useUsernameAndPassword } from '../usernameAndPassword/useUsernameAndPassword'
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
{=/ providers.isUsernameAndPasswordAuthEnabled =}
{=# providers.isEmailAuthEnabled =}
import { useEmail } from '../email/useEmail'
{=/ isEmailAuthEnabled =}
{=/ providers.isEmailAuthEnabled =}

{=# areBothSocialAndPasswordBasedAuthEnabled =}
const OrContinueWith = styled('div', {
Expand Down Expand Up @@ -105,15 +105,15 @@ const SocialAuthButtons = styled('div', {
}
})
{=/ isSocialAuthEnabled =}
{=# isGoogleAuthEnabled =}
{=# providers.isGoogleAuthEnabled =}
const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
{=/ isGoogleAuthEnabled =}
{=# isKeycloakAuthEnabled =}
{=/ providers.isGoogleAuthEnabled =}
{=# providers.isKeycloakAuthEnabled =}
const keycloakSignInUrl = `${config.apiUrl}{= keycloakSignInPath =}`
{=/ isKeycloakAuthEnabled =}
{=# isGitHubAuthEnabled =}
{=/ providers.isKeycloakAuthEnabled =}
{=# providers.isGitHubAuthEnabled =}
const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
{=/ isGitHubAuthEnabled =}
{=/ providers.isGitHubAuthEnabled =}

{=!
// Since we allow users to add additional fields to the signup form, we don't
Expand Down Expand Up @@ -151,16 +151,16 @@ export const LoginSignupForm = ({
{=/ isAnyPasswordBasedAuthEnabled =}
const hookForm = useForm<LoginSignupFormFields>()
const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
{=# isUsernameAndPasswordAuthEnabled =}
{=# providers.isUsernameAndPasswordAuthEnabled =}
const { handleSubmit } = useUsernameAndPassword({
isLogin,
onError: onErrorHandler,
onSuccess() {
history.push('{= onAuthSucceededRedirectTo =}')
},
});
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
{=/ providers.isUsernameAndPasswordAuthEnabled =}
{=# providers.isEmailAuthEnabled =}
const { handleSubmit } = useEmail({
isLogin,
onError: onErrorHandler,
Expand All @@ -172,7 +172,7 @@ export const LoginSignupForm = ({
history.push('{= onAuthSucceededRedirectTo =}')
},
});
{=/ isEmailAuthEnabled =}
{=/ providers.isEmailAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}
async function onSubmit (data) {
setIsLoading(true);
Expand All @@ -191,17 +191,17 @@ export const LoginSignupForm = ({
<SocialAuth>
<SocialAuthLabel>{cta} with</SocialAuthLabel>
<SocialAuthButtons gap='large' direction={socialButtonsDirection}>
{=# isGoogleAuthEnabled =}
{=# providers.isGoogleAuthEnabled =}
<SocialButton href={googleSignInUrl}><SocialIcons.Google/></SocialButton>
{=/ isGoogleAuthEnabled =}
{=/ providers.isGoogleAuthEnabled =}

{=# isKeycloakAuthEnabled =}
{=# providers.isKeycloakAuthEnabled =}
<SocialButton href={keycloakSignInUrl}><SocialIcons.Keycloak/></SocialButton>
{=/ isKeycloakAuthEnabled =}
{=/ providers.isKeycloakAuthEnabled =}

{=# isGitHubAuthEnabled =}
{=# providers.isGitHubAuthEnabled =}
<SocialButton href={gitHubSignInUrl}><SocialIcons.GitHub/></SocialButton>
{=/ isGitHubAuthEnabled =}
{=/ providers.isGitHubAuthEnabled =}
</SocialAuthButtons>
</SocialAuth>
{=/ isSocialAuthEnabled =}
Expand All @@ -217,7 +217,7 @@ export const LoginSignupForm = ({
{=/ areBothSocialAndPasswordBasedAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}
<Form onSubmit={hookFormHandleSubmit(onSubmit)}>
{=# isUsernameAndPasswordAuthEnabled =}
{=# providers.isUsernameAndPasswordAuthEnabled =}
<FormItemGroup>
<FormLabel>Username</FormLabel>
<FormInput
Expand All @@ -229,8 +229,8 @@ export const LoginSignupForm = ({
/>
{errors.username && <FormError>{errors.username.message}</FormError>}
</FormItemGroup>
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
{=/ providers.isUsernameAndPasswordAuthEnabled =}
{=# providers.isEmailAuthEnabled =}
<FormItemGroup>
<FormLabel>E-mail</FormLabel>
<FormInput
Expand All @@ -242,7 +242,7 @@ export const LoginSignupForm = ({
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
</FormItemGroup>
{=/ isEmailAuthEnabled =}
{=/ providers.isEmailAuthEnabled =}
<FormItemGroup>
<FormLabel>Password</FormLabel>
<FormInput
Expand Down
98 changes: 18 additions & 80 deletions waspc/src/Wasp/AppSpec/App/Auth.hs
Original file line number Diff line number Diff line change
@@ -1,32 +1,23 @@
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TemplateHaskell #-}

module Wasp.AppSpec.App.Auth
( Auth (..),
AuthMethods (..),
ExternalAuthConfig (..),
EmailAuthConfig (..),
UsernameAndPasswordConfig (..),
isUsernameAndPasswordAuthEnabled,
isExternalAuthEnabled,
isGoogleAuthEnabled,
isKeycloakAuthEnabled,
isGitHubAuthEnabled,
isEmailAuthEnabled,
userSignupFieldsForEmailAuth,
userSignupFieldsForUsernameAuth,
userSignupFieldsForExternalAuth,
generateIsAuthMethodEnabled,
)
where

import Data.Data (Data)
import Data.Maybe (isJust)
import Wasp.AppSpec.App.Auth.EmailVerification (EmailVerificationConfig)
import Wasp.AppSpec.App.Auth.PasswordReset (PasswordResetConfig)
import Wasp.AppSpec.App.EmailSender (EmailFromField)
import Language.Haskell.TH
import Wasp.AppSpec.App.Auth.AuthMethods (AuthMethod, generateAuthMethods)
import Wasp.AppSpec.Core.Ref (Ref)
import Wasp.AppSpec.Entity (Entity)
import Wasp.AppSpec.ExtImport (ExtImport)
import Wasp.Util (toLowerFirst)

$(generateAuthMethods)

data Auth = Auth
{ userEntity :: Ref Entity,
Expand All @@ -37,67 +28,14 @@ data Auth = Auth
}
deriving (Show, Eq, Data)

data AuthMethods = AuthMethods
{ usernameAndPassword :: Maybe UsernameAndPasswordConfig,
google :: Maybe ExternalAuthConfig,
gitHub :: Maybe ExternalAuthConfig,
keycloak :: Maybe ExternalAuthConfig,
email :: Maybe EmailAuthConfig
}
deriving (Show, Eq, Data)

data UsernameAndPasswordConfig = UsernameAndPasswordConfig
{ userSignupFields :: Maybe ExtImport
}
deriving (Show, Eq, Data)

data ExternalAuthConfig = ExternalAuthConfig
{ configFn :: Maybe ExtImport,
userSignupFields :: Maybe ExtImport
}
deriving (Show, Eq, Data)

data EmailAuthConfig = EmailAuthConfig
{ userSignupFields :: Maybe ExtImport,
fromField :: EmailFromField,
emailVerification :: EmailVerificationConfig,
passwordReset :: PasswordResetConfig
}
deriving (Show, Eq, Data)

isUsernameAndPasswordAuthEnabled :: Auth -> Bool
isUsernameAndPasswordAuthEnabled = isJust . usernameAndPassword . methods

isExternalAuthEnabled :: Auth -> Bool
isExternalAuthEnabled auth =
any
($ auth)
-- NOTE: Make sure to add new external auth methods here.
[ isGoogleAuthEnabled,
isGitHubAuthEnabled,
isKeycloakAuthEnabled
]

isGoogleAuthEnabled :: Auth -> Bool
isGoogleAuthEnabled = isJust . google . methods

isKeycloakAuthEnabled :: Auth -> Bool
isKeycloakAuthEnabled = isJust . keycloak . methods

isGitHubAuthEnabled :: Auth -> Bool
isGitHubAuthEnabled = isJust . gitHub . methods

isEmailAuthEnabled :: Auth -> Bool
isEmailAuthEnabled = isJust . email . methods

-- These helper functions are used to avoid ambiguity when using the
-- `userSignupFields` function (otherwise we need to use the DuplicateRecordFields
-- extension in each module that uses them).
userSignupFieldsForEmailAuth :: EmailAuthConfig -> Maybe ExtImport
userSignupFieldsForEmailAuth = userSignupFields

userSignupFieldsForUsernameAuth :: UsernameAndPasswordConfig -> Maybe ExtImport
userSignupFieldsForUsernameAuth = userSignupFields

userSignupFieldsForExternalAuth :: ExternalAuthConfig -> Maybe ExtImport
userSignupFieldsForExternalAuth = userSignupFields
generateIsAuthMethodEnabled :: Q [Dec]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since TH can be hard to undrsatnd, and here it certainly is a bit ahrd to read, it would be good if you could document here, in a comment above it, what does this TH generate.

generateIsAuthMethodEnabled = do
let authMethodNames = map (\method -> (mkName . show $ method, (mkName . toLowerFirst . show) method)) [minBound .. maxBound :: AuthMethod]
clauses <- mapM generateClause authMethodNames
return [FunD (mkName "isAuthMethodEnabled") clauses]
where
generateClause :: (Name, Name) -> Q Clause
generateClause (authMethodCtor, authMethodName) = do
let authVar = mkName "auth"
body <- [|isJust ($(varE authMethodName) ($(varE (mkName "methods")) $(varE authVar)))|]
return $ Clause [ConP authMethodCtor [], VarP authVar] (NormalB body) []
105 changes: 105 additions & 0 deletions waspc/src/Wasp/AppSpec/App/Auth/AuthMethods.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TemplateHaskell #-}

module Wasp.AppSpec.App.Auth.AuthMethods
( generateAuthMethods,
userSignupFieldsForEmailAuth,
userSignupFieldsForUsernameAuth,
userSignupFieldsForExternalAuth,
isAuthMethodExternal,
AuthMethod (..),
UsernameAndPasswordConfig (..),
ExternalAuthConfig (..),
EmailAuthConfig (..),
)
where

import Data.Data (Data)
import Language.Haskell.TH
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unqalified, unlisted import -> not a good practice!
I would suggest importing it qualified as TH.

import Language.Haskell.TH.Syntax (VarBangType)
import Wasp.AppSpec.App.Auth.EmailVerification (EmailVerificationConfig)
import Wasp.AppSpec.App.Auth.PasswordReset (PasswordResetConfig)
import Wasp.AppSpec.App.EmailSender (EmailFromField)
import Wasp.AppSpec.ExtImport (ExtImport)
import Wasp.Util (toLowerFirst)

data AuthMethod = UsernameAndPassword | Email | Google | Keycloak | GitHub deriving (Show, Enum, Bounded)

data UsernameAndPasswordConfig = UsernameAndPasswordConfig
{ userSignupFields :: Maybe ExtImport
}
deriving (Show, Eq, Data)

data ExternalAuthConfig = ExternalAuthConfig
{ configFn :: Maybe ExtImport,
userSignupFields :: Maybe ExtImport
}
deriving (Show, Eq, Data)

data EmailAuthConfig = EmailAuthConfig
{ userSignupFields :: Maybe ExtImport,
fromField :: EmailFromField,
emailVerification :: EmailVerificationConfig,
passwordReset :: PasswordResetConfig
}
deriving (Show, Eq, Data)

configType :: AuthMethod -> Name
configType UsernameAndPassword = ''UsernameAndPasswordConfig
configType Email = ''EmailAuthConfig
configType Google = ''ExternalAuthConfig
configType Keycloak = ''ExternalAuthConfig
configType GitHub = ''ExternalAuthConfig

-- Generate the AuthMethods data type
-- data AuthMethods = AuthMethods
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to me like you ended up defining this relationship anyway above! Becauwe you defined it via configType at the end, only to then generate AuthMethods from that. So did you really gain anything?

-- { usernameAndPassword :: Maybe UsernameAndPasswordConfig,
-- google :: Maybe ExternalAuthConfig,
-- gitHub :: Maybe ExternalAuthConfig,
-- keycloak :: Maybe ExternalAuthConfig,
-- email :: Maybe EmailAuthConfig
-- ...
-- } deriving (Show, Eq, Data)
generateAuthMethods :: Q [Dec]
generateAuthMethods = do
let authMethodsName = mkName "AuthMethods"
let authMethodsCtorName = mkName "AuthMethods"
let fields = generateField <$> [UsernameAndPassword .. GitHub]
-- data AuthMethods
let authMethods =
dataD
(cxt [])
authMethodsName
[]
Nothing
[recC authMethodsCtorName fields]
[derivClause Nothing [[t|Show|], [t|Eq|], [t|Data|]]]
sequence [authMethods]
where
-- usernameAndPassword :: Maybe UsernameAndPasswordConfig
generateField :: AuthMethod -> Q VarBangType
generateField authMethod = do
let fieldName = mkName (toLowerFirst (show authMethod))
let fieldConfigType = configType authMethod
let fieldType = appT (conT ''Maybe) (conT fieldConfigType)
let fieldStrictness = bang noSourceUnpackedness noSourceStrictness
varBangType fieldName $ bangType fieldStrictness fieldType

-- These helper functions are used to avoid ambiguity when using the
-- `userSignupFields` function (otherwise we need to use the DuplicateRecordFields
-- extension in each module that uses them).
userSignupFieldsForEmailAuth :: EmailAuthConfig -> Maybe ExtImport
userSignupFieldsForEmailAuth = userSignupFields

userSignupFieldsForUsernameAuth :: UsernameAndPasswordConfig -> Maybe ExtImport
userSignupFieldsForUsernameAuth = userSignupFields

userSignupFieldsForExternalAuth :: ExternalAuthConfig -> Maybe ExtImport
userSignupFieldsForExternalAuth = userSignupFields
Comment on lines +92 to +99
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you can do, if you want is, use typeclass for something like this, which has a method userSignupFields, and each of these types will implement it. That might be a bit more elegant, depending on how you use it though, what you really need.


isAuthMethodExternal :: AuthMethod -> Bool
isAuthMethodExternal Google = True
isAuthMethodExternal Keycloak = True
isAuthMethodExternal GitHub = True
isAuthMethodExternal _ = False
12 changes: 12 additions & 0 deletions waspc/src/Wasp/AppSpec/App/Auth/IsEnabled.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Wasp.AppSpec.App.Auth.IsEnabled where

import Language.Haskell.TH
import Wasp.AppSpec.App.Auth (Auth (..), AuthMethods (..), generateIsAuthMethodEnabled)
import Wasp.AppSpec.App.Auth.AuthMethods (AuthMethod(..), isAuthMethodExternal)

$(generateIsAuthMethodEnabled)

isExternalAuthEnabled :: Auth -> Bool
isExternalAuthEnabled auth = any check [minBound .. maxBound :: AuthMethod]
where
check method = isAuthMethodExternal method && isAuthMethodEnabled method auth
6 changes: 4 additions & 2 deletions waspc/src/Wasp/AppSpec/Valid.hs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import Wasp.AppSpec.App (App)
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App as App
import qualified Wasp.AppSpec.App.Auth as Auth
import Wasp.AppSpec.App.Auth.AuthMethods (AuthMethod (Email, UsernameAndPassword))
import qualified Wasp.AppSpec.App.Auth.IsEnabled as Auth.IsEnabled
import qualified Wasp.AppSpec.App.Client as Client
import qualified Wasp.AppSpec.App.Db as AS.Db
import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender
Expand Down Expand Up @@ -169,7 +171,7 @@ validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec =
| areBothAuthMethodsUsed
]
where
areBothAuthMethodsUsed = Auth.isEmailAuthEnabled auth && Auth.isUsernameAndPasswordAuthEnabled auth
areBothAuthMethodsUsed = Auth.IsEnabled.isAuthMethodEnabled Email auth && Auth.IsEnabled.isAuthMethodEnabled UsernameAndPassword auth

validateDbIsPostgresIfPgBossUsed :: AppSpec -> [ValidationError]
validateDbIsPostgresIfPgBossUsed spec =
Expand All @@ -182,7 +184,7 @@ validateEmailSenderIsDefinedIfEmailAuthIsUsed :: AppSpec -> [ValidationError]
validateEmailSenderIsDefinedIfEmailAuthIsUsed spec = case App.auth app of
Nothing -> []
Just auth ->
if Auth.isEmailAuthEnabled auth && isNothing (App.emailSender app)
if Auth.IsEnabled.isAuthMethodEnabled Email auth && isNothing (App.emailSender app)
then [GenericValidationError "app.emailSender must be specified when using email auth. You can use the Dummy email sender for development purposes."]
else []
where
Expand Down
Loading
Loading