Adds Auth and Email Sender recipes

Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
This commit is contained in:
Mihovil Ilakovac 2023-09-26 11:59:50 +02:00
parent e58f4ecacc
commit bd9c09471f
16 changed files with 392 additions and 23 deletions

View File

@ -5,11 +5,13 @@ import Data.List.NonEmpty (fromList)
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require)
import Wasp.Cli.Command.UseRecipe.Auth (useAuth)
import Wasp.Cli.Command.UseRecipe.EmailSender (useEmailSender)
import Wasp.Cli.Command.UseRecipe.Tailwind (useTailwind)
import qualified Wasp.Cli.Interactive as Interactive
data Recipe = Recipe
{ recipeName :: String,
recipeDescription :: String,
execute :: Command ()
}
@ -18,20 +20,27 @@ instance Show Recipe where
instance Interactive.Option Recipe where
showOption = show
showOptionDescription _ = Nothing
showOptionDescription = Just . recipeDescription
useRecipe :: [String] -> Command ()
useRecipe _args = do
InWaspProject waspProjectDir <- require
InWaspProject _ <- require
let recipes =
[ Recipe
{ recipeName = "tailwind",
execute = useTailwind waspProjectDir
{ recipeName = "Tailwind",
recipeDescription = "Add support for Tailwind CSS",
execute = useTailwind
},
Recipe
{ recipeName = "auth",
{ recipeName = "Auth",
recipeDescription = "Add authentication to your app",
execute = useAuth
},
Recipe
{ recipeName = "Email sender",
recipeDescription = "Set up an email sender",
execute = useEmailSender
}
]

View File

@ -1,9 +1,14 @@
{-# LANGUAGE InstanceSigs #-}
module Wasp.Cli.Command.UseRecipe.Auth where
import Control.Monad.IO.Class (liftIO)
import Data.List.NonEmpty (fromList)
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.UseRecipe.Auth.Email (useEmail)
import Wasp.Cli.Command.UseRecipe.Auth.Local (useLocal)
import Wasp.Cli.Command.UseRecipe.Auth.Social (useGithub, useGoogle)
import qualified Wasp.Cli.Interactive as Interactive
import qualified Wasp.Message as Msg
@ -16,20 +21,23 @@ instance Show AuthMethod where
show Github = "Github"
instance Interactive.Option AuthMethod where
showOption :: AuthMethod -> String
showOption = show
showOptionDescription :: AuthMethod -> Maybe String
showOptionDescription _ = Nothing
useAuth :: Command ()
useAuth = do
method <- liftIO selectMethod
cliSendMessageC $ Msg.Start "Installing authentication..."
-- Create React pages for each method
-- Edit the Wasp file (or prompt the user to do so)
cliSendMessageC $ Msg.Success $ "Installed " <> show method <> " authentication!"
cliSendMessageC $ Msg.Start $ "Installing " <> show method <> " authentication..."
useMethod method
where
useMethod Email = useEmail
useMethod UsernameAndPassword = useLocal
useMethod Google = useGoogle
useMethod Github = useGithub
methods =
[ Email,
UsernameAndPassword,
@ -39,6 +47,6 @@ useAuth = do
selectMethod =
Interactive.askToChoose
"What authentication method do you want to use?"
"Which authentication method do you want to use?"
(fromList methods)
Interactive.ChooserConfig {Interactive.hasDefaultOption = False}

View File

@ -0,0 +1,60 @@
module Wasp.Cli.Command.UseRecipe.Auth.Common where
import Control.Monad.IO.Class (liftIO)
import StrongPath (Abs, Dir, Path', Rel, reldir, relfile, (</>))
import Wasp.Cli.Command (Command, require)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject))
import Wasp.Cli.Command.UseRecipe.Common (RecipesDir, copyFileIfDoesNotExist)
import Wasp.Cli.Common (WaspProjectDir)
data AuthRecipeDir
authDirInRecipesDir :: Path' (Rel RecipesDir) (Dir AuthRecipeDir)
authDirInRecipesDir = [reldir|auth|]
copyLoginPage :: Command ()
copyLoginPage = do
authTargetDir <- getAuthTargetDir
liftIO $ copyFileIfDoesNotExist (authDirInRecipesDir </> loginPagePath) (authTargetDir </> loginPagePath)
where
loginPagePath = [relfile|LoginPage.jsx|]
copySignupPage :: Command ()
copySignupPage = do
authTargetDir <- getAuthTargetDir
liftIO $ copyFileIfDoesNotExist (authDirInRecipesDir </> signupPagePath) (authTargetDir </> signupPagePath)
where
signupPagePath = [relfile|SignupPage.jsx|]
copyRequestPasswordResetPage :: Command ()
copyRequestPasswordResetPage = do
authTargetDir <- getAuthTargetDir
liftIO $ copyFileIfDoesNotExist (authDirInRecipesDir </> requestPasswordResetPagePath) (authTargetDir </> requestPasswordResetPagePath)
where
requestPasswordResetPagePath = [relfile|RequestPasswordResetPage.jsx|]
copyResetPasswordPage :: Command ()
copyResetPasswordPage = do
authTargetDir <- getAuthTargetDir
liftIO $ copyFileIfDoesNotExist (authDirInRecipesDir </> resetPasswordPagePath) (authTargetDir </> resetPasswordPagePath)
where
resetPasswordPagePath = [relfile|ResetPasswordPage.jsx|]
copyEmailVerificationPage :: Command ()
copyEmailVerificationPage = do
authTargetDir <- getAuthTargetDir
liftIO $ copyFileIfDoesNotExist (authDirInRecipesDir </> emailVerificationPagePath) (authTargetDir </> emailVerificationPagePath)
where
emailVerificationPagePath = [relfile|EmailVerificationPage.jsx|]
showRouteAndPageDSL :: String -> String -> String -> String
showRouteAndPageDSL routeName path pageName = unlines [route, page]
where
route = "route " <> routeName <> " { path: \"" <> path <> "\", to: " <> pageName <> " }"
page = "page " <> pageName <> " { component: import { " <> pageName <> " } from \"@client/auth/" <> pageName <> ".jsx\" }"
getAuthTargetDir :: Command (Path' Abs (Dir WaspProjectDir))
getAuthTargetDir = do
InWaspProject waspProjectDir <- require
let authTargetDir = waspProjectDir </> [reldir|src/client/auth|]
return authTargetDir

View File

@ -0,0 +1,64 @@
module Wasp.Cli.Command.UseRecipe.Auth.Email where
import Wasp.Cli.Command
import Wasp.Cli.Command.Message
import Wasp.Cli.Command.UseRecipe.Auth.Common (copyEmailVerificationPage, copyLoginPage, copyRequestPasswordResetPage, copyResetPasswordPage, copySignupPage, showRouteAndPageDSL)
import Wasp.Cli.Command.UseRecipe.EmailSender (useEmailSender)
import qualified Wasp.Message as Msg
import qualified Wasp.Util.Terminal as Term
useEmail :: Command ()
useEmail = do
useEmailSender
copyLoginPage
copySignupPage
copyRequestPasswordResetPage
copyResetPasswordPage
copyEmailVerificationPage
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following entity definition to your main.wasp file:\n"
cliSendMessageC $
Msg.Info $
unlines
[ "entity User {=psl",
" id Int @id @default(autoincrement())",
" email String? @unique",
" password String?",
" isEmailVerified Boolean @default(false)",
" emailVerificationSentAt DateTime?",
" passwordResetSentAt DateTime?",
"psl=}"
]
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following auth block to the app block in your main.wasp file:\n"
cliSendMessageC $
Msg.Info $
unlines
[ "auth: {",
" userEntity: User,",
" methods: {",
" email: {",
" fromField: {",
" name: \"My App\",",
" email: \"myapp@domain.com\"",
" },",
" emailVerification: {",
" clientRoute: EmailVerificationRoute,",
" },",
" passwordReset: {",
" clientRoute: ResetPasswordRoute",
" },",
" allowUnverifiedLogin: false,",
" },",
" },",
" onAuthFailedRedirectTo: \"/login\"",
"}"
]
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following routes and pages to your main.wasp file:\n"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "LoginRoute" "/login" "LoginPage"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "SignupRoute" "/signup" "SignupPage"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "RequestPasswordResetRoute" "/request-password-reset" "RequestPasswordResetPage"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "ResetPasswordRoute" "/reset-password" "ResetPasswordPage"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "EmailVerificationRoute" "/email-verification" "EmailVerificationPage"

View File

@ -0,0 +1,40 @@
module Wasp.Cli.Command.UseRecipe.Auth.Local where
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.UseRecipe.Auth.Common (copyLoginPage, copySignupPage, showRouteAndPageDSL)
import qualified Wasp.Message as Msg
import qualified Wasp.Util.Terminal as Term
useLocal :: Command ()
useLocal = do
copyLoginPage
copySignupPage
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following entity definition to your main.wasp file:\n"
cliSendMessageC $
Msg.Info $
unlines
[ "entity User {=psl",
" id Int @id @default(autoincrement())",
" username String @unique",
" password String",
"psl=}"
]
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following auth block to the app block in your main.wasp file:\n"
cliSendMessageC $
Msg.Info $
unlines
[ "auth: {",
" userEntity: User,",
" methods: {",
" usernameAndPassword: {},",
" },",
" onAuthFailedRedirectTo: \"/login\"",
"}"
]
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following routes and pages to your main.wasp file:\n"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "LoginRoute" "/login" "LoginPage"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "SignupRoute" "/signup" "SignupPage"

View File

@ -0,0 +1,79 @@
module Wasp.Cli.Command.UseRecipe.Auth.Social where
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.UseRecipe.Auth.Common (copyLoginPage, showRouteAndPageDSL)
import Wasp.Cli.Command.UseRecipe.Common (appendToServerEnv)
import qualified Wasp.Message as Msg
import qualified Wasp.Util.Terminal as Term
useGoogle :: Command ()
useGoogle = do
copyLoginPage
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following entities to your main.wasp file:\n"
printSocialLoginEntities
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following auth block to the app block in your main.wasp file:\n"
printSocialLoginAuthBlock "google"
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following routes and pages to your main.wasp file:\n"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "LoginRoute" "/login" "LoginPage"
appendToServerEnv $ unlines ["GOOGLE_CLIENT_ID=\"\"", "GOOGLE_CLIENT_SECRET=\"\""]
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Fill the values for GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env.server file."
useGithub :: Command ()
useGithub = do
copyLoginPage
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following entities to your main.wasp file:\n"
printSocialLoginEntities
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following auth block to the app block in your main.wasp file:\n"
printSocialLoginAuthBlock "github"
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following routes and pages to your main.wasp file:\n"
cliSendMessageC $ Msg.Info $ showRouteAndPageDSL "LoginRoute" "/login" "LoginPage"
appendToServerEnv $ unlines ["GITHUB_CLIENT_ID=\"\"", "GITHUB_CLIENT_SECRET=\"\""]
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Fill the values for GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env.server file."
printSocialLoginEntities :: Command ()
printSocialLoginEntities = do
cliSendMessageC $
Msg.Info $
unlines
[ "entity User {=psl",
" id Int @id @default(autoincrement())",
" externalAuthAssociations SocialLogin[]",
"psl=}"
]
cliSendMessageC $
Msg.Info $
unlines
[ "entity SocialLogin {=psl",
" id Int @id @default(autoincrement())",
" provider String",
" providerId String",
" user User @relation(fields: [userId], references: [id], onDelete: Cascade)",
" userId Int",
" createdAt DateTime @default(now())",
" @@unique([provider, providerId, userId])",
"psl=}"
]
printSocialLoginAuthBlock :: String -> Command ()
printSocialLoginAuthBlock provider = do
cliSendMessageC $
Msg.Info $
unlines
[ "auth: {",
" userEntity: User,",
" externalAuthEntity: SocialLogin,",
" methods: {",
" " <> provider <> ": {},",
" },",
" onAuthFailedRedirectTo: \"/login\"",
"}"
]

View File

@ -1,10 +1,14 @@
module Wasp.Cli.Command.UseRecipe.Common where
import Control.Monad (unless)
import StrongPath (Abs, Dir, File, Path', Rel, reldir, toFilePath, (</>))
import Control.Monad.IO.Class (liftIO)
import StrongPath (Abs, Dir, File, Path', Rel, reldir, relfile, toFilePath, (</>))
import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist)
import System.FilePath (takeDirectory)
import Wasp.Cli.Command (Command, require)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject))
import qualified Wasp.Data
import Wasp.Util.IO (appendToFile)
data RecipesDir
@ -24,9 +28,14 @@ copyFileIfDoesNotExist pathInRecipesDir pathInProjectDir = do
let pathInProjectDirStr = toFilePath pathInProjectDir
let pathInRecipesDirStr = toFilePath $ recipesDir </> pathInRecipesDir
-- _ <- error $ "Copying " <> pathInRecipesDirStr <> " to " <> pathInProjectDirStr
isExistingFile <- doesFileExist pathInProjectDirStr
unless isExistingFile $ do
createDirectoryIfMissing True $ takeDirectory pathInProjectDirStr
copyFile pathInRecipesDirStr pathInProjectDirStr
appendToServerEnv :: String -> Command ()
appendToServerEnv content = do
InWaspProject waspProjectDir <- require
let serverEnvPath = waspProjectDir </> [relfile|.env.server|]
liftIO $ appendToFile serverEnvPath content

View File

@ -0,0 +1,66 @@
module Wasp.Cli.Command.UseRecipe.EmailSender where
import Control.Monad.IO.Class (liftIO)
import Data.List.NonEmpty (fromList)
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.UseRecipe.Common (appendToServerEnv)
import qualified Wasp.Cli.Interactive as Interactive
import qualified Wasp.Message as Msg
import qualified Wasp.Util.Terminal as Term
data EmailProvider = SMTP | SendGrid | Mailgun
instance Show EmailProvider where
show SMTP = "SMTP"
show SendGrid = "SendGrid"
show Mailgun = "Mailgun"
instance Interactive.Option EmailProvider where
showOption = show
showOptionDescription _ = Nothing
useEmailSender :: Command ()
useEmailSender = do
provider <- liftIO selectProvider
cliSendMessageC $ Msg.Start $ "Setting up " <> show provider <> " email sender..."
useProvider provider
where
providers =
[ SMTP,
SendGrid,
Mailgun
]
selectProvider =
Interactive.askToChoose
"Which email sender do you want to use?"
(fromList providers)
Interactive.ChooserConfig {Interactive.hasDefaultOption = False}
useProvider :: EmailProvider -> Command ()
useProvider SMTP = useEmailProvider "SMTP" ["SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD"]
useProvider SendGrid = useEmailProvider "SendGrid" ["SENDGRID_API_KEY"]
useProvider Mailgun = useEmailProvider "Mailgun" ["MAILGUN_API_KEY", "MAILGUN_DOMAIN"]
useEmailProvider :: String -> [String] -> Command ()
useEmailProvider providerName providerEnvVariableNames = do
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] "Add the following email sender block to the app block in your main.wasp file:\n"
cliSendMessageC $
Msg.Info $
unlines
[ "emailSender: {",
" provider: " <> providerName,
"}"
]
appendToServerEnv $ unlines $ map (<> "=\"\"") providerEnvVariableNames
cliSendMessageC $ Msg.Info $ Term.applyStyles [Term.Bold] $ "Fill the values for " <> joinForSentence providerEnvVariableNames <> " in .env.server file."
joinForSentence :: [String] -> String
joinForSentence [] = ""
joinForSentence [x] = x
joinForSentence [x, y] = x <> " and " <> y
joinForSentence (x : xs) = x <> ", " <> joinForSentence xs

View File

@ -2,21 +2,20 @@ module Wasp.Cli.Command.UseRecipe.Tailwind where
import Control.Monad.Cont (MonadIO (liftIO))
import StrongPath
( Abs,
Dir,
( Dir,
Path',
Rel,
reldir,
relfile,
(</>),
)
import Wasp.Cli.Command (Command)
import Wasp.Cli.Command (Command, require)
import Wasp.Cli.Command.Message (cliSendMessageC)
import Wasp.Cli.Command.Require (InWaspProject (InWaspProject))
import Wasp.Cli.Command.UseRecipe.Common
( RecipesDir,
copyFileIfDoesNotExist,
)
import Wasp.Cli.Common (WaspProjectDir)
import qualified Wasp.Message as Msg
data TailwindDir
@ -24,8 +23,11 @@ data TailwindDir
tailwindDirInRecipesDir :: Path' (Rel RecipesDir) (Dir TailwindDir)
tailwindDirInRecipesDir = [reldir|tailwind|]
useTailwind :: Path' Abs (Dir WaspProjectDir) -> Command ()
useTailwind waspProjectDir = do
useTailwind :: Command ()
useTailwind = do
InWaspProject waspProjectDir <- require
let tailwindTargetDir = waspProjectDir
cliSendMessageC $ Msg.Start "Setting up Tailwind config files..."
liftIO $ do
@ -44,7 +46,5 @@ useTailwind waspProjectDir = do
"You can now use Tailwind classes in your CSS files."
]
where
tailwindTargetDir = waspProjectDir
tailwindConfigPath = [relfile|tailwind.config.cjs|]
postcssConfigPath = [relfile|postcss.config.cjs|]

View File

@ -0,0 +1,5 @@
import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail";
export function EmailVerificationPage() {
return <VerifyEmailForm />;
}

View File

@ -0,0 +1,5 @@
import { LoginForm } from "@wasp/auth/forms/Login";
export function LoginPage() {
return <LoginForm />;
}

View File

@ -0,0 +1,5 @@
import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword";
export function RequestPasswordResetPage() {
return <ForgotPasswordForm />;
}

View File

@ -0,0 +1,5 @@
import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword";
export function ResetPasswordPage() {
return <ResetPasswordForm />;
}

View File

@ -0,0 +1,5 @@
import { SignupForm } from "@wasp/auth/forms/Signup";
export function SignupPage() {
return <SignupForm />;
}

View File

@ -12,6 +12,7 @@ module Wasp.Util.IO
removeFile,
isDirectoryEmpty,
writeFileFromText,
appendToFile,
)
where
@ -111,3 +112,6 @@ isDirectoryEmpty :: Path' Abs (Dir d) -> IO Bool
isDirectoryEmpty dirPath = do
(files, dirs) <- listDirectory dirPath
return $ null files && null dirs
appendToFile :: Path' Abs (File f) -> String -> IO ()
appendToFile = P.appendFile . SP.fromAbsFile

View File

@ -480,6 +480,11 @@ library cli-lib
Wasp.Cli.Command.UseRecipe.Auth
Wasp.Cli.Command.UseRecipe.Tailwind
Wasp.Cli.Command.UseRecipe.Common
Wasp.Cli.Command.UseRecipe.Auth.Common
Wasp.Cli.Command.UseRecipe.Auth.Email
Wasp.Cli.Command.UseRecipe.Auth.Local
Wasp.Cli.Command.UseRecipe.Auth.Social
Wasp.Cli.Command.UseRecipe.EmailSender
Wasp.Cli.Common
Wasp.Cli.Terminal
Wasp.Cli.Command.Message