diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a415179dd..7d3efa1ac5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,11 +41,14 @@ All that's required is to create an app. And make a tutorial or a blog post to h Or you can re-build your existing pet project with Wasp. That would be cool! -## Documentation +## Documentation & Blog It may sound like the simplest one, but it's super valuable! If you've found an issue, a broken link or if something was unclear on our [website](https://wasp-lang.dev/) - please, feel free to fix it :) +Please make sure to **base your feature branches and PRs on the `release` branch** instead of `main`, since that's the one that is deployed to the website. + [**Documentation issues for beginners can be found here.**](https://github.com/wasp-lang/wasp/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22+label%3Adocumentation) +If you'd like to write a blog post about Wasp, please contact us via [Discord](https://discord.gg/zKFDFrsHa9) to discuss the topic and the details. Happy hacking! \ No newline at end of file diff --git a/examples/streaming/src/client/vite-env.d.ts b/examples/streaming/src/client/vite-env.d.ts index 1623b9c79c..11f02fe2a0 100644 --- a/examples/streaming/src/client/vite-env.d.ts +++ b/examples/streaming/src/client/vite-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/todo-typescript/main.wasp b/examples/todo-typescript/main.wasp index cd84cad132..9d4d180e24 100644 --- a/examples/todo-typescript/main.wasp +++ b/examples/todo-typescript/main.wasp @@ -1,6 +1,6 @@ app TodoTypescript { wasp: { - version: "^0.11.0" + version: "^0.12.0" }, title: "ToDo TypeScript", diff --git a/examples/waspello/README.md b/examples/waspello/README.md index 395cd3903e..5fb8c11ea6 100644 --- a/examples/waspello/README.md +++ b/examples/waspello/README.md @@ -10,9 +10,9 @@ The backend is hosted on Fly.io at https://waspello.fly.dev. # Development ### Database -Wasp needs the Postgres database running. Check out the docs for details on [how to setup PostgreSQL](https://wasp-lang.dev/docs/language/features#postgresql) +Wasp needs the Postgres database running. -You can use `wasp start db` to start a PostgreSQL locally using Docker. +Easiest way to do this is to use `wasp start db` to start a PostgreSQL locally using Docker. ### Env variables Copy `env.server` to `.env.server` and fill in the values. diff --git a/examples/waspleau/README.md b/examples/waspleau/README.md index cd7cd2b0a6..8e9779267f 100644 --- a/examples/waspleau/README.md +++ b/examples/waspleau/README.md @@ -1,7 +1,7 @@ # Waspleau Welcome to the Waspleau example! This is a small Wasp project that tracks status of wasp-lang/wasp repo via a nice looking dashboard. -It pulls in data via [Jobs](https://wasp-lang.dev/docs/language/features#jobs) and stores them in the database. +It pulls in data via [Jobs](https://wasp-lang.dev/docs/advanced/jobs) and stores them in the database. This example project can serve as a good starting point for building your own dashboard with Wasp, that regularly pulls in external data by using Jobs Wasp feature. diff --git a/waspc/.hlint.yaml b/waspc/.hlint.yaml index 1b21470602..66a623d845 100644 --- a/waspc/.hlint.yaml +++ b/waspc/.hlint.yaml @@ -12,3 +12,4 @@ - ignore: {name: Use $>} # I find it makes code harder to read if enforced. - ignore: {name: Use list comprehension} # We can decide this on our own. - ignore: {name: Use ++} # I sometimes prefer concat over ++ due to the nicer formatting / extensibility. +- ignore: {name: Redundant lambda} # Sometimes it is nicer to create explicit lambda then function. diff --git a/waspc/README.md b/waspc/README.md index d4fd2cf423..b4baf4cde0 100644 --- a/waspc/README.md +++ b/waspc/README.md @@ -362,11 +362,17 @@ NOTE: If building of your commit is suddenly taking much longer time, it might b If it happens just once every so it is probably nothing to worry about. If it happens consistently, we should look into it. ### Typical Release Process -- Ensure that all starter templates in `starter` repo are working with the version of Wasp we are about to release and upgrade their version of Wasp to the new one. +- Starter templates + - Context: they are used by used by `wasp new`, you can find reference to them in `Wasp.Cli. ... .StarterTemplates`. + - In `StarterTemplates.hs` file, update git tag to new version of Wasp we are about to release (e.g. `wasp-v0.13.1-template`). + - Ensure that all starter templates are working with this new version of Wasp. + Update Wasp version in their main.wasp files. Finally, in their repos (for those templates that are on Github), + create new git tag that is the same as the new one in `StarterTemplates.hs` (e.g. `wasp-v0.13.1-template`). - ChangeLog.md and version in waspc.cabal should already be up to date, but double check that they are correct and update them if needed. Also consider enriching and polishing ChangeLog.md a bit even if all the data is already there. Also check that ChangeLog has correction version of wasp specified. - If you modified ChangeLog.md or waspc.cabal, create a PR, wait for approval and all the checks (CI) to pass, then squash and merge mentioned PR into `main`. - Update your local repository state to have all remote changes (`git fetch`). - Update `main` to contain changes from `release` by running `git merge release` while on the `main` branch. Resolve any conflicts. +- Take a versioned "snapshot" of the current docs by running `npm run docusaurus docs:version {version}` in the [web](/web) dir. Check the README in the `web` dir for more details. Commit this change to `main`. - Fast-forward `release` to this new, updated `main` by running `git merge main` while on the `release` branch. - Make sure you are on `release` and then run `./new-release 0.x.y.z`. - This will do some checks, tag it with new release version, and push it. diff --git a/waspc/cli/exe/Main.hs b/waspc/cli/exe/Main.hs index 5acb94ed85..24eddb03a4 100644 --- a/waspc/cli/exe/Main.hs +++ b/waspc/cli/exe/Main.hs @@ -19,7 +19,6 @@ import Wasp.Cli.Command.CreateNewProject (createNewProject) import qualified Wasp.Cli.Command.CreateNewProject.AI as Command.CreateNewProject.AI import Wasp.Cli.Command.Db (runDbCommand) import qualified Wasp.Cli.Command.Db.Migrate as Command.Db.Migrate -import qualified Wasp.Cli.Command.Db.Reset as Command.Db.Reset import qualified Wasp.Cli.Command.Db.Seed as Command.Db.Seed import qualified Wasp.Cli.Command.Db.Studio as Command.Db.Studio import Wasp.Cli.Command.Deploy (deploy) @@ -158,7 +157,7 @@ printUsage = cmd " start Runs Wasp app in development mode, watching for file changes.", cmd " start db Starts managed development database for you.", cmd " db [args] Executes a database command. Run 'wasp db' for more info.", - cmd " clean Deletes all generated code and other cached artifacts.", + cmd " clean Deletes all generated code, all cached artifacts, and the node_modules dir.", " Wasp equivalent of 'have you tried closing and opening it again?'.", cmd " build Generates full web app code, ready for deployment. Use when deploying or ejecting.", cmd " deploy Deploys your Wasp app to cloud hosting providers.", @@ -200,7 +199,6 @@ dbCli :: [String] -> IO () dbCli args = case args of ["start"] -> runCommand Command.Start.Db.start "migrate-dev" : optionalMigrateArgs -> runDbCommand $ Command.Db.Migrate.migrateDev optionalMigrateArgs - ["reset"] -> runDbCommand Command.Db.Reset.reset ["seed"] -> runDbCommand $ Command.Db.Seed.seed Nothing ["seed", seedName] -> runDbCommand $ Command.Db.Seed.seed $ Just seedName ["studio"] -> runDbCommand Command.Db.Studio.studio diff --git a/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs b/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs index 1e90148e57..0a4e24f085 100644 --- a/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs +++ b/waspc/cli/src/Wasp/Cli/Command/BashCompletion.hs @@ -26,7 +26,26 @@ bashCompletion = do ["db", cmdPrefix] -> listMatchingCommands cmdPrefix dbSubCommands _ -> liftIO . putStrLn $ "" where - commands = ["new", "version", "waspls", "start", "db", "clean", "uninstall", "build", "telemetry", "deps", "info", "completion", "completion:generate"] + commands = + [ "new", + "new:ai", + "version", + "waspls", + "completion", + "completion:generate", + "uninstall", + "start", + "db", + "clean", + "build", + "deploy", + "telemetry", + "deps", + "dockerfile", + "info", + "test", + "studio" + ] dbSubCommands = ["migrate-dev", "studio"] listMatchingCommands :: String -> [String] -> Command () listMatchingCommands cmdPrefix cmdList = listCommands $ filter (cmdPrefix `isPrefixOf`) cmdList diff --git a/waspc/cli/src/Wasp/Cli/Command/Build.hs b/waspc/cli/src/Wasp/Cli/Command/Build.hs index 1f3b6e8d28..591fc1256e 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Build.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Build.hs @@ -16,13 +16,13 @@ import Wasp.Cli.Command (Command, CommandError (..)) import Wasp.Cli.Command.Compile (compileIOWithOptions, printCompilationResult) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Common import Wasp.Cli.Message (cliSendMessage) import Wasp.CompileOptions (CompileOptions (..)) import qualified Wasp.Generator import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning)) import qualified Wasp.Message as Msg -import Wasp.Project (CompileError, CompileWarning) +import Wasp.Project (CompileError, CompileWarning, WaspProjectDir) +import Wasp.Project.Common (buildDirInDotWaspDir, dotWaspDirInWaspProjectDir) -- | Builds Wasp project that the current working directory is part of. -- Does all the steps, from analysis to generation, and at the end writes generated code @@ -35,8 +35,8 @@ build :: Command () build = do InWaspProject waspProjectDir <- require let buildDir = - waspProjectDir Common.dotWaspDirInWaspProjectDir - Common.buildDirInDotWaspDir + waspProjectDir dotWaspDirInWaspProjectDir + buildDirInDotWaspDir buildDirFilePath = SP.fromAbsDir buildDir doesBuildDirExist <- liftIO $ doesDirectoryExist buildDirFilePath @@ -58,16 +58,14 @@ build = do CommandError "Building of wasp project failed" $ show (length errors) ++ " errors found" buildIO :: - Path' Abs (Dir Common.WaspProjectDir) -> + Path' Abs (Dir WaspProjectDir) -> Path' Abs (Dir Wasp.Generator.ProjectRootDir) -> IO ([CompileWarning], [CompileError]) buildIO waspProjectDir buildDir = compileIOWithOptions options waspProjectDir buildDir where options = CompileOptions - { externalClientCodeDirPath = waspProjectDir Common.extClientCodeDirInWaspProjectDir, - externalServerCodeDirPath = waspProjectDir Common.extServerCodeDirInWaspProjectDir, - externalSharedCodeDirPath = waspProjectDir Common.extSharedCodeDirInWaspProjectDir, + { waspProjectDirPath = waspProjectDir, isBuild = True, sendMessage = cliSendMessage, -- Ignore "DB needs migration warnings" during build, as that is not a required step. diff --git a/waspc/cli/src/Wasp/Cli/Command/Clean.hs b/waspc/cli/src/Wasp/Cli/Command/Clean.hs index 87a3b1346e..e5dccab574 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Clean.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Clean.hs @@ -3,26 +3,18 @@ module Wasp.Cli.Command.Clean ) where -import Control.Monad.IO.Class (liftIO) import qualified StrongPath as SP -import System.Directory - ( doesDirectoryExist, - removeDirectoryRecursive, - ) import Wasp.Cli.Command (Command) -import Wasp.Cli.Command.Message (cliSendMessageC) +import Wasp.Cli.Command.Common (deleteDirectoryIfExistsVerbosely) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Common -import qualified Wasp.Message as Msg +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, nodeModulesDirInWaspProjectDir) clean :: Command () clean = do InWaspProject waspProjectDir <- require - let dotWaspDirFp = SP.toFilePath $ waspProjectDir SP. Common.dotWaspDirInWaspProjectDir - cliSendMessageC $ Msg.Start "Deleting .wasp/ directory..." - doesDotWaspDirExist <- liftIO $ doesDirectoryExist dotWaspDirFp - if doesDotWaspDirExist - then do - liftIO $ removeDirectoryRecursive dotWaspDirFp - cliSendMessageC $ Msg.Success "Deleted .wasp/ directory." - else cliSendMessageC $ Msg.Success "Nothing to delete: .wasp directory does not exist." + + let dotWaspDir = waspProjectDir SP. dotWaspDirInWaspProjectDir + let nodeModulesDir = waspProjectDir SP. nodeModulesDirInWaspProjectDir + + deleteDirectoryIfExistsVerbosely dotWaspDir + deleteDirectoryIfExistsVerbosely nodeModulesDir diff --git a/waspc/cli/src/Wasp/Cli/Command/Common.hs b/waspc/cli/src/Wasp/Cli/Command/Common.hs index 8f40ab7249..df5c396114 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Common.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Common.hs @@ -1,17 +1,23 @@ module Wasp.Cli.Command.Common ( readWaspCompileInfo, throwIfExeIsNotAvailable, + deleteDirectoryIfExistsVerbosely, ) where import Control.Monad.Except import qualified Control.Monad.Except as E import StrongPath (Abs, Dir, Path') +import qualified StrongPath as SP import StrongPath.Operations -import System.Directory (findExecutable) +import System.Directory + ( findExecutable, + ) import Wasp.Cli.Command (Command, CommandError (..)) -import qualified Wasp.Cli.Common as Cli.Common +import Wasp.Cli.Command.Message (cliSendMessageC) +import qualified Wasp.Message as Msg import Wasp.Project (WaspProjectDir) +import qualified Wasp.Project.Common as Project.Common import Wasp.Util (ifM) import qualified Wasp.Util.IO as IOUtil @@ -23,9 +29,9 @@ readWaspCompileInfo waspDir = (return "No compile information found") where dotWaspInfoFile = - waspDir Cli.Common.dotWaspDirInWaspProjectDir - Cli.Common.generatedCodeDirInDotWaspDir - Cli.Common.dotWaspInfoFileInGeneratedCodeDir + waspDir Project.Common.dotWaspDirInWaspProjectDir + Project.Common.generatedCodeDirInDotWaspDir + Project.Common.dotWaspInfoFileInGeneratedCodeDir throwIfExeIsNotAvailable :: String -> String -> Command () throwIfExeIsNotAvailable exeName explanationMsg = do @@ -34,3 +40,16 @@ throwIfExeIsNotAvailable exeName explanationMsg = do Nothing -> E.throwError $ CommandError ("Couldn't find `" <> exeName <> "` executable") explanationMsg + +deleteDirectoryIfExistsVerbosely :: Path' Abs (Dir d) -> Command () +deleteDirectoryIfExistsVerbosely dir = do + cliSendMessageC $ Msg.Start $ "Deleting the " ++ dirName ++ " directory..." + dirExist <- liftIO $ IOUtil.doesDirectoryExist dir + if dirExist + then do + liftIO $ IOUtil.removeDirectory dir + cliSendMessageC $ Msg.Success $ "Deleted the " ++ dirName ++ " directory." + else do + cliSendMessageC $ Msg.Success $ "Nothing to delete: The " ++ dirName ++ " directory does not exist." + where + dirName = SP.toFilePath $ basename dir diff --git a/waspc/cli/src/Wasp/Cli/Command/Compile.hs b/waspc/cli/src/Wasp/Cli/Command/Compile.hs index f5a208aecf..c1bb97d15f 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Compile.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Compile.hs @@ -20,13 +20,13 @@ import qualified Wasp.AppSpec as AS import Wasp.Cli.Command (Command, CommandError (..)) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Common import Wasp.Cli.Message (cliSendMessage) import Wasp.CompileOptions (CompileOptions (..)) import qualified Wasp.Generator import qualified Wasp.Message as Msg import Wasp.Project (CompileError, CompileWarning, WaspProjectDir) import qualified Wasp.Project +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) -- | Same like 'compileWithOptions', but with default compile options. compile :: Command [CompileWarning] @@ -47,8 +47,8 @@ compileWithOptions :: CompileOptions -> Command [CompileWarning] compileWithOptions options = do InWaspProject waspProjectDir <- require let outDir = - waspProjectDir Common.dotWaspDirInWaspProjectDir - Common.generatedCodeDirInDotWaspDir + waspProjectDir dotWaspDirInWaspProjectDir + generatedCodeDirInDotWaspDir cliSendMessageC $ Msg.Start "Compiling wasp project..." (warnings, errors) <- liftIO $ compileIOWithOptions options waspProjectDir outDir @@ -115,9 +115,7 @@ compileIOWithOptions options waspProjectDir outDir = defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions defaultCompileOptions waspProjectDir = CompileOptions - { externalServerCodeDirPath = waspProjectDir Common.extServerCodeDirInWaspProjectDir, - externalClientCodeDirPath = waspProjectDir Common.extClientCodeDirInWaspProjectDir, - externalSharedCodeDirPath = waspProjectDir Common.extSharedCodeDirInWaspProjectDir, + { waspProjectDirPath = waspProjectDir, isBuild = False, sendMessage = cliSendMessage, generatorWarningsFilter = id diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs index 920eb222c5..6dd78fd804 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject.hs @@ -5,11 +5,12 @@ where import Control.Monad.IO.Class (liftIO) import Data.Function ((&)) +import qualified StrongPath as SP import Wasp.Cli.Command (Command) import Wasp.Cli.Command.Call (Arguments) import qualified Wasp.Cli.Command.CreateNewProject.AI as AI import Wasp.Cli.Command.CreateNewProject.ArgumentsParser (parseNewProjectArgs) -import Wasp.Cli.Command.CreateNewProject.Common (printGettingStartedInstructions, throwProjectCreationError) +import qualified Wasp.Cli.Command.CreateNewProject.Common as Common import Wasp.Cli.Command.CreateNewProject.ProjectDescription ( NewProjectDescription (..), obtainNewProjectDescription, @@ -18,22 +19,24 @@ import Wasp.Cli.Command.CreateNewProject.StarterTemplates ( DirBasedTemplateMetadata (_path), StarterTemplate (..), getStarterTemplates, + getTemplateStartingInstructions, ) +import Wasp.Cli.Command.CreateNewProject.StarterTemplates.GhRepo (createProjectOnDiskFromGhRepoTemplate) import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local (createProjectOnDiskFromLocalTemplate) -import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote (createProjectOnDiskFromRemoteTemplate) import Wasp.Cli.Command.Message (cliSendMessageC) import qualified Wasp.Message as Msg +import qualified Wasp.Util.Terminal as Term -- | It receives all of the arguments that were passed to the `wasp new` command. createNewProject :: Arguments -> Command () createNewProject args = do - newProjectArgs <- parseNewProjectArgs args & either throwProjectCreationError return - starterTemplates <- liftIO getStarterTemplates + newProjectArgs <- parseNewProjectArgs args & either Common.throwProjectCreationError return + let starterTemplates = getStarterTemplates newProjectDescription <- obtainNewProjectDescription newProjectArgs starterTemplates createProjectOnDisk newProjectDescription - liftIO $ printGettingStartedInstructions $ _absWaspProjectDir newProjectDescription + liftIO $ printGettingStartedInstructionsForProject newProjectDescription createProjectOnDisk :: NewProjectDescription -> Command () createProjectOnDisk @@ -45,9 +48,18 @@ createProjectOnDisk } = do cliSendMessageC $ Msg.Start $ "Creating your project from the \"" ++ show template ++ "\" template..." case template of - RemoteStarterTemplate metadata -> - createProjectOnDiskFromRemoteTemplate absWaspProjectDir projectName appName $ _path metadata + GhRepoStarterTemplate ghRepoRef metadata -> + createProjectOnDiskFromGhRepoTemplate absWaspProjectDir projectName appName ghRepoRef $ _path metadata LocalStarterTemplate metadata -> liftIO $ createProjectOnDiskFromLocalTemplate absWaspProjectDir projectName appName $ _path metadata AiGeneratedStarterTemplate -> AI.createNewProjectInteractiveOnDisk absWaspProjectDir appName + +-- | This function assumes that the project dir was created inside the current working directory. +printGettingStartedInstructionsForProject :: NewProjectDescription -> IO () +printGettingStartedInstructionsForProject projectDescription = do + let projectDirName = init . SP.toFilePath . SP.basename $ _absWaspProjectDir projectDescription + let instructions = getTemplateStartingInstructions projectDirName $ _template projectDescription + putStrLn $ Term.applyStyles [Term.Green] $ "Created new Wasp app in ./" ++ projectDirName ++ " directory!" + putStrLn "" + putStrLn instructions diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs index f4527ab775..aec5371649 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/AI.hs @@ -39,8 +39,8 @@ import Wasp.Cli.Command.CreateNewProject.ProjectDescription parseWaspProjectNameIntoAppName, ) import Wasp.Cli.Command.CreateNewProject.StarterTemplates (readWaspProjectSkeletonFiles) -import Wasp.Cli.Common (WaspProjectDir) import qualified Wasp.Cli.Interactive as Interactive +import Wasp.Project.Common (WaspProjectDir) import qualified Wasp.Util as U import qualified Wasp.Util.Aeson as Utils.Aeson import qualified Wasp.Util.Terminal as T diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs index acb0657436..5446040773 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/Common.hs @@ -2,17 +2,12 @@ module Wasp.Cli.Command.CreateNewProject.Common ( throwProjectCreationError, throwInvalidTemplateNameUsedError, defaultWaspVersionBounds, - printGettingStartedInstructions, ) where import Control.Monad.Except (throwError) -import StrongPath (Abs, Dir, Path') -import qualified StrongPath as SP import Wasp.Cli.Command (Command, CommandError (..)) -import Wasp.Cli.Common (WaspProjectDir) import qualified Wasp.SemanticVersion as SV -import qualified Wasp.Util.Terminal as Term import qualified Wasp.Version as WV throwProjectCreationError :: String -> Command a @@ -26,17 +21,3 @@ throwInvalidTemplateNameUsedError = defaultWaspVersionBounds :: String defaultWaspVersionBounds = show (SV.backwardsCompatibleWith WV.waspVersion) - --- | This function assumes that the project dir is created inside the current working directory --- when it prints the instructions. -printGettingStartedInstructions :: Path' Abs (Dir WaspProjectDir) -> IO () -printGettingStartedInstructions absProjectDir = do - let projectFolder = init . SP.toFilePath . SP.basename $ absProjectDir -{- ORMOLU_DISABLE -} - putStrLn $ Term.applyStyles [Term.Green] $ "Created new Wasp app in ./" ++ projectFolder ++ " directory!" - putStrLn "To run it, do:" - putStrLn "" - putStrLn $ Term.applyStyles [Term.Bold] $ " cd " ++ projectFolder - putStrLn $ Term.applyStyles [Term.Bold] " wasp start" - putStrLn "" -{- ORMOLU_ENABLE -} diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs index 2bfb45d4ba..b99046be55 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates.hs @@ -7,78 +7,227 @@ module Wasp.Cli.Command.CreateNewProject.StarterTemplates findTemplateByString, defaultStarterTemplate, readWaspProjectSkeletonFiles, + getTemplateStartingInstructions, ) where -import Data.Either (fromRight) import Data.Foldable (find) import Data.Text (Text) -import StrongPath (File', Path, Rel, System, reldir, ()) -import qualified Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote.Github as Github -import Wasp.Cli.Common (WaspProjectDir) +import StrongPath (Dir', File', Path, Path', Rel, Rel', System, reldir, ()) +import qualified System.FilePath as FP +import qualified Wasp.Cli.GithubRepo as GhRepo import qualified Wasp.Cli.Interactive as Interactive import qualified Wasp.Data as Data +import Wasp.Project.Common (WaspProjectDir) import Wasp.Util.IO (listDirectoryDeep, readFileStrict) +import qualified Wasp.Util.Terminal as Term data StarterTemplate - = RemoteStarterTemplate DirBasedTemplateMetadata - | LocalStarterTemplate DirBasedTemplateMetadata - | AiGeneratedStarterTemplate - deriving (Eq) + = -- | Template from a Github repo. + GhRepoStarterTemplate !GhRepo.GithubRepoRef !DirBasedTemplateMetadata + | -- | Template from a disk, that comes bundled with wasp CLI. + LocalStarterTemplate !DirBasedTemplateMetadata + | -- | Template that will be dynamically generated by Wasp AI based on user's input. + AiGeneratedStarterTemplate data DirBasedTemplateMetadata = DirBasedTemplateMetadata - { _name :: String, - _path :: String, -- Path to a directory containing template files. - _description :: String + { _name :: !String, + _path :: !(Path' Rel' Dir'), -- Path to a directory containing template files. + _description :: !String, + _buildStartingInstructions :: !StartingInstructionsBuilder } - deriving (Eq, Show) instance Show StarterTemplate where - show (RemoteStarterTemplate metadata) = _name metadata + show (GhRepoStarterTemplate _ metadata) = _name metadata show (LocalStarterTemplate metadata) = _name metadata show AiGeneratedStarterTemplate = "ai-generated" instance Interactive.IsOption StarterTemplate where showOption = show - showOptionDescription (RemoteStarterTemplate metadata) = Just $ _description metadata + + showOptionDescription (GhRepoStarterTemplate _ metadata) = Just $ _description metadata showOptionDescription (LocalStarterTemplate metadata) = Just $ _description metadata showOptionDescription AiGeneratedStarterTemplate = Just "🤖 Describe an app in a couple of sentences and have Wasp AI generate initial code for you. (experimental)" -getStarterTemplates :: IO [StarterTemplate] -getStarterTemplates = do - remoteTemplates <- fromRight [] <$> fetchRemoteStarterTemplates - return $ localTemplates ++ remoteTemplates ++ [AiGeneratedStarterTemplate] - -fetchRemoteStarterTemplates :: IO (Either String [StarterTemplate]) -fetchRemoteStarterTemplates = do - fmap extractTemplateNames <$> Github.fetchRemoteTemplatesGithubData - where - extractTemplateNames :: [Github.RemoteTemplateGithubData] -> [StarterTemplate] - -- Each folder in the repo is a template. - extractTemplateNames = - map - ( \metadata -> - RemoteStarterTemplate $ - DirBasedTemplateMetadata - { _name = Github._name metadata, - _path = Github._path metadata, - _description = Github._description metadata - } - ) - -localTemplates :: [StarterTemplate] -localTemplates = [defaultStarterTemplate] +type StartingInstructionsBuilder = String -> String + +{- HLINT ignore getTemplateStartingInstructions "Redundant $" -} + +-- | Returns instructions for running the newly created (from the template) Wasp project. +-- Instructions assume that user is positioned right next to the just created project directory, +-- whose name is provided via projectDirName. +getTemplateStartingInstructions :: String -> StarterTemplate -> String +getTemplateStartingInstructions projectDirName = \case + GhRepoStarterTemplate _ metadata -> _buildStartingInstructions metadata projectDirName + LocalStarterTemplate metadata -> _buildStartingInstructions metadata projectDirName + AiGeneratedStarterTemplate -> + unlines + [ styleText $ "To run your new app, do:", + styleCode $ " cd " <> projectDirName, + styleCode $ " wasp db migrate-dev", + styleCode $ " wasp start" + ] + +getStarterTemplates :: [StarterTemplate] +getStarterTemplates = + [ defaultStarterTemplate, + todoTsStarterTemplate, + openSaasStarterTemplate, + embeddingsStarterTemplate, + AiGeneratedStarterTemplate + ] defaultStarterTemplate :: StarterTemplate -defaultStarterTemplate = +defaultStarterTemplate = basicStarterTemplate + +{- HLINT ignore basicStarterTemplate "Redundant $" -} + +basicStarterTemplate :: StarterTemplate +basicStarterTemplate = LocalStarterTemplate $ DirBasedTemplateMetadata - { _name = "basic", - _path = "basic", - _description = "Simple starter template with a single page." + { _path = [reldir|basic|], + _name = "basic", + _description = "Simple starter template with a single page.", + _buildStartingInstructions = \projectDirName -> + unlines + [ styleText $ "To run your new app, do:", + styleCode $ " cd " <> projectDirName, + styleCode $ " wasp db start" + ] } +{- HLINT ignore openSaasStarterTemplate "Redundant $" -} + +openSaasStarterTemplate :: StarterTemplate +openSaasStarterTemplate = + simpleGhRepoTemplate + ("open-saas", [reldir|.|]) + ( "saas", + "Everything a SaaS needs! Comes with Auth, ChatGPT API, Tailwind, Stripe payments and more." + <> " Check out https://opensaas.sh/ for more details." + ) + ( \projectDirName -> + unlines + [ styleText $ "To run your new app, follow the instructions below:", + styleText $ "", + styleText $ " 1. Position into app's root directory:", + styleCode $ " cd " <> projectDirName FP. "app", + styleText $ "", + styleText $ " 2. Run the development database (and leave it running):", + styleCode $ " wasp db start", + styleText $ "", + styleText $ " 3. Open new terminal window (or tab) in that same dir and continue in it.", + styleText $ "", + styleText $ " 4. Apply initial database migrations:", + styleCode $ " wasp db migrate-dev", + styleText $ "", + styleText $ " 5. Create initial dot env file from the template:", + styleCode $ " cp .env.server.example .env.server", + styleText $ "", + styleText $ " 6. Last step: run the app!", + styleCode $ " wasp start", + styleText $ "", + styleText $ "Check the README for additional guidance and the link to docs!" + ] + ) + +{- HLINT ignore todoTsStarterTemplate "Redundant $" -} + +todoTsStarterTemplate :: StarterTemplate +todoTsStarterTemplate = + simpleGhRepoTemplate + ("starters", [reldir|todo-ts|]) + ( "todo-ts", + "Simple but well-rounded Wasp app implemented with Typescript & full-stack type safety." + ) + ( \projectDirName -> + unlines + [ styleText $ "To run your new app, do:", + styleCode $ " cd " ++ projectDirName, + styleCode $ " wasp db migrate-dev", + styleCode $ " wasp start", + styleText $ "", + styleText $ "Check the README for additional guidance!" + ] + ) + +{- Functions for styling instructions. Their names are on purpose of same length, for nicer code formatting. -} + +styleCode :: String -> String +styleCode = Term.applyStyles [Term.Bold] + +styleText :: String -> String +styleText = id + +{- -} + +{- HLINT ignore embeddingsStarterTemplate "Redundant $" -} + +embeddingsStarterTemplate :: StarterTemplate +embeddingsStarterTemplate = + simpleGhRepoTemplate + ("starters", [reldir|embeddings|]) + ( "embeddings", + "Comes with code for generating vector embeddings and performing vector similarity search." + ) + ( \projectDirName -> + unlines + [ styleText $ "To run your new app, follow the instructions below:", + styleText $ "", + styleText $ " 1. Position into app's root directory:", + styleCode $ " cd " <> projectDirName, + styleText $ "", + styleText $ " 2. Create initial dot env file from the template and fill in your API keys:", + styleCode $ " cp .env.server.example .env.server", + styleText $ " Fill in your API keys!", + styleText $ "", + styleText $ " 3. Run the development database (and leave it running):", + styleCode $ " wasp db start", + styleText $ "", + styleText $ " 4. Open new terminal window (or tab) in that same dir and continue in it.", + styleText $ "", + styleText $ " 5. Apply initial database migrations:", + styleCode $ " wasp db migrate-dev", + styleText $ "", + styleText $ " 6. Run wasp seed script that will generate embeddings from the text files in src/shared/docs:", + styleCode $ " wasp db seed", + styleText $ "", + styleText $ " 7. Last step: run the app!", + styleCode $ " wasp start", + styleText $ "", + styleText $ "Check the README for more detailed instructions and additional guidance!" + ] + ) + +simpleGhRepoTemplate :: (String, Path' Rel' Dir') -> (String, String) -> StartingInstructionsBuilder -> StarterTemplate +simpleGhRepoTemplate (repoName, tmplPathInRepo) (tmplDisplayName, tmplDescription) buildStartingInstructions = + GhRepoStarterTemplate + ( GhRepo.GithubRepoRef + { GhRepo._repoOwner = waspGhOrgName, + GhRepo._repoName = repoName, + GhRepo._repoReferenceName = waspVersionTemplateGitTag + } + ) + ( DirBasedTemplateMetadata + { _name = tmplDisplayName, + _description = tmplDescription, + _path = tmplPathInRepo, + _buildStartingInstructions = buildStartingInstructions + } + ) + +waspGhOrgName :: String +waspGhOrgName = "wasp-lang" + +-- NOTE: As version of Wasp CLI changes, so we should update this tag name here, +-- and also create it on gh repos of templates. +-- By tagging templates for each version of Wasp CLI, we ensure that each release of +-- Wasp CLI uses correct version of templates, that work with it. +waspVersionTemplateGitTag :: String +waspVersionTemplateGitTag = "wasp-v0.12-template" + findTemplateByString :: [StarterTemplate] -> String -> Maybe StarterTemplate findTemplateByString templates query = find ((== query) . show) templates diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/GhRepo.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/GhRepo.hs new file mode 100644 index 0000000000..e859f74fbe --- /dev/null +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/GhRepo.hs @@ -0,0 +1,27 @@ +module Wasp.Cli.Command.CreateNewProject.StarterTemplates.GhRepo + ( createProjectOnDiskFromGhRepoTemplate, + ) +where + +import Control.Monad.IO.Class (liftIO) +import StrongPath (Abs, Dir, Dir', Path', Rel') +import Wasp.Cli.Command (Command) +import Wasp.Cli.Command.CreateNewProject.Common (throwProjectCreationError) +import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName) +import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating (replaceTemplatePlaceholdersInWaspFile) +import Wasp.Cli.GithubRepo (GithubRepoRef, fetchFolderFromGithubRepoToDisk) +import Wasp.Project (WaspProjectDir) + +createProjectOnDiskFromGhRepoTemplate :: + Path' Abs (Dir WaspProjectDir) -> + NewProjectName -> + NewProjectAppName -> + GithubRepoRef -> + Path' Rel' Dir' -> + Command () +createProjectOnDiskFromGhRepoTemplate absWaspProjectDir projectName appName ghRepoRef templatePathInRepo = do + fetchTheTemplateFromGhToDisk >>= either throwProjectCreationError pure + liftIO $ replaceTemplatePlaceholdersInWaspFile appName projectName absWaspProjectDir + where + fetchTheTemplateFromGhToDisk = do + liftIO $ fetchFolderFromGithubRepoToDisk ghRepoRef templatePathInRepo absWaspProjectDir diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs index 655e88815e..ec3e85303f 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Local.hs @@ -3,10 +3,8 @@ module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local ) where -import Data.Maybe (fromJust) import Path.IO (copyDirRecur) -import StrongPath (Abs, Dir, Path', reldir, ()) -import qualified StrongPath as SP +import StrongPath (Abs, Dir, Dir', Path', Rel', reldir, ()) import StrongPath.Path (toPathAbsDir) import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName) import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating (replaceTemplatePlaceholdersInWaspFile) @@ -14,16 +12,16 @@ import qualified Wasp.Data as Data import Wasp.Project (WaspProjectDir) createProjectOnDiskFromLocalTemplate :: - Path' Abs (Dir WaspProjectDir) -> NewProjectName -> NewProjectAppName -> String -> IO () + Path' Abs (Dir WaspProjectDir) -> NewProjectName -> NewProjectAppName -> Path' Rel' Dir' -> IO () createProjectOnDiskFromLocalTemplate absWaspProjectDir projectName appName templatePath = do copyLocalTemplateToNewProjectDir templatePath replaceTemplatePlaceholdersInWaspFile appName projectName absWaspProjectDir where - copyLocalTemplateToNewProjectDir :: String -> IO () + copyLocalTemplateToNewProjectDir :: Path' Rel' Dir' -> IO () copyLocalTemplateToNewProjectDir templateDir = do dataDir <- Data.getAbsDataDirPath let absLocalTemplateDir = - dataDir [reldir|Cli/templates|] (fromJust . SP.parseRelDir $ templateDir) + dataDir [reldir|Cli/templates|] templateDir let absSkeletonTemplateDir = dataDir [reldir|Cli/templates/skeleton|] -- First we copy skeleton files, which form the basis of any Wasp project, diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote.hs deleted file mode 100644 index 7317a38866..0000000000 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote.hs +++ /dev/null @@ -1,31 +0,0 @@ -module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote - ( createProjectOnDiskFromRemoteTemplate, - ) -where - -import Control.Monad.IO.Class (liftIO) -import Data.Maybe (fromJust) -import StrongPath (Abs, Dir, Path') -import qualified StrongPath as SP -import Wasp.Cli.Command (Command) -import Wasp.Cli.Command.CreateNewProject.Common (throwProjectCreationError) -import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName) -import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote.Github (starterTemplateGithubRepo) -import Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating (replaceTemplatePlaceholdersInWaspFile) -import Wasp.Cli.GithubRepo (fetchFolderFromGithubRepoToDisk) -import Wasp.Project (WaspProjectDir) - -createProjectOnDiskFromRemoteTemplate :: - Path' Abs (Dir WaspProjectDir) -> - NewProjectName -> - NewProjectAppName -> - String -> - Command () -createProjectOnDiskFromRemoteTemplate absWaspProjectDir projectName appName templatePath = do - fetchGithubTemplateToDisk absWaspProjectDir templatePath >>= either throwProjectCreationError pure - liftIO $ replaceTemplatePlaceholdersInWaspFile appName projectName absWaspProjectDir - where - fetchGithubTemplateToDisk :: Path' Abs (Dir WaspProjectDir) -> String -> Command (Either String ()) - fetchGithubTemplateToDisk projectDir templatePathInRepo = do - let templateFolderPath = fromJust . SP.parseRelDir $ templatePathInRepo - liftIO $ fetchFolderFromGithubRepoToDisk starterTemplateGithubRepo templateFolderPath projectDir diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote/Github.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote/Github.hs deleted file mode 100644 index db93fe6578..0000000000 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Remote/Github.hs +++ /dev/null @@ -1,36 +0,0 @@ -module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote.Github where - -import Data.Aeson (FromJSON (parseJSON), withObject, (.:)) -import Wasp.Cli.GithubRepo (GithubRepoRef (..)) -import qualified Wasp.Cli.GithubRepo as GR - -starterTemplateGithubRepo :: GithubRepoRef -starterTemplateGithubRepo = - GithubRepoRef - { _repoOwner = "wasp-lang", - _repoName = "starters", - _repoReferenceName = "main" - } - -starterTemplatesDataGithubFilePath :: FilePath -starterTemplatesDataGithubFilePath = "templates.json" - -fetchRemoteTemplatesGithubData :: IO (Either String [RemoteTemplateGithubData]) -fetchRemoteTemplatesGithubData = GR.fetchRepoFileContents starterTemplateGithubRepo starterTemplatesDataGithubFilePath - -data RemoteTemplateGithubData = RemoteTemplateGithubData - { _name :: String, - _description :: String, - _path :: String - } - deriving (Show, Eq) - -instance FromJSON RemoteTemplateGithubData where - parseJSON = withObject "RemoteTemplateGithubData" $ \obj -> - RemoteTemplateGithubData - <$> obj - .: "name" - <*> obj - .: "description" - <*> obj - .: "path" diff --git a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs index 02eac1ba0b..8347151624 100644 --- a/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs +++ b/waspc/cli/src/Wasp/Cli/Command/CreateNewProject/StarterTemplates/Templating.hs @@ -3,20 +3,26 @@ module Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating where import Data.List (foldl') import Data.Text (Text) import qualified Data.Text as T -import StrongPath (Abs, Dir, File, Path', relfile, ()) +import StrongPath (Abs, Dir, File, Path') import Wasp.Cli.Command.CreateNewProject.Common (defaultWaspVersionBounds) import Wasp.Cli.Command.CreateNewProject.ProjectDescription (NewProjectAppName, NewProjectName) +import Wasp.Project.Analyze (findWaspFile) import Wasp.Project.Common (WaspProjectDir) import qualified Wasp.Util.IO as IOUtil --- Template file for wasp file has placeholders in it that we want to replace +-- | Template file for wasp file has placeholders in it that we want to replace -- in the .wasp file we have written to the disk. -replaceTemplatePlaceholdersInWaspFile :: NewProjectAppName -> NewProjectName -> Path' Abs (Dir WaspProjectDir) -> IO () -replaceTemplatePlaceholdersInWaspFile appName projectName projectDir = - updateFileContent absMainWaspFile $ replacePlaceholders waspFileReplacements +-- If no .wasp file was found in the project, do nothing. +replaceTemplatePlaceholdersInWaspFile :: + NewProjectAppName -> NewProjectName -> Path' Abs (Dir WaspProjectDir) -> IO () +replaceTemplatePlaceholdersInWaspFile appName projectName projectDir = do + findWaspFile projectDir >>= \case + Nothing -> return () + Just absMainWaspFile -> + updateFileContentWith absMainWaspFile (replacePlaceholders waspFileReplacements) where - updateFileContent :: Path' Abs (File f) -> (Text -> Text) -> IO () - updateFileContent absFilePath updateFn = + updateFileContentWith :: Path' Abs (File f) -> (Text -> Text) -> IO () + updateFileContentWith absFilePath updateFn = IOUtil.readFileStrict absFilePath >>= IOUtil.writeFileFromText absFilePath . updateFn replacePlaceholders :: [(String, String)] -> Text -> Text @@ -24,7 +30,6 @@ replaceTemplatePlaceholdersInWaspFile appName projectName projectDir = where replacePlaceholder content' (placeholder, value) = T.replace (T.pack placeholder) (T.pack value) content' - absMainWaspFile = projectDir [relfile|main.wasp|] waspFileReplacements = [ ("__waspAppName__", show appName), ("__waspProjectName__", show projectName), diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs index ee6bc64d78..5f4e21c7d5 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Db/Migrate.hs @@ -10,11 +10,11 @@ import StrongPath (Abs, Dir, Path', ()) import Wasp.Cli.Command (Command, CommandError (..)) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Cli.Common import Wasp.Generator.Common (ProjectRootDir) import Wasp.Generator.DbGenerator.Common (MigrateArgs (..), defaultMigrateArgs) import qualified Wasp.Generator.DbGenerator.Operations as DbOps import qualified Wasp.Message as Msg +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) import Wasp.Project.Db.Migrations (DbMigrationsDir, dbMigrationsDirInWaspProjectDir) -- | NOTE(shayne): Performs database schema migration (based on current schema) in the generated project. @@ -26,8 +26,8 @@ migrateDev optionalMigrateArgs = do let waspDbMigrationsDir = waspProjectDir dbMigrationsDirInWaspProjectDir let projectRootDir = waspProjectDir - Cli.Common.dotWaspDirInWaspProjectDir - Cli.Common.generatedCodeDirInDotWaspDir + dotWaspDirInWaspProjectDir + generatedCodeDirInDotWaspDir migrateDatabase optionalMigrateArgs projectRootDir waspDbMigrationsDir diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs index 9220a671d8..a293636b5c 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs @@ -8,15 +8,15 @@ import StrongPath (()) import Wasp.Cli.Command (Command) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Common import Wasp.Generator.DbGenerator.Operations (dbReset) import qualified Wasp.Message as Msg +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) reset :: Command () reset = do InWaspProject waspProjectDir <- require let genProjectDir = - waspProjectDir Common.dotWaspDirInWaspProjectDir Common.generatedCodeDirInDotWaspDir + waspProjectDir dotWaspDirInWaspProjectDir generatedCodeDirInDotWaspDir cliSendMessageC $ Msg.Start "Resetting the database..." diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs index ee7dfb85d7..176962f097 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs @@ -19,15 +19,15 @@ import Wasp.Cli.Command (Command, CommandError (CommandError)) import Wasp.Cli.Command.Compile (analyze) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Common import Wasp.Generator.DbGenerator.Operations (dbSeed) import qualified Wasp.Message as Msg +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) seed :: Maybe String -> Command () seed maybeUserProvidedSeedName = do InWaspProject waspProjectDir <- require let genProjectDir = - waspProjectDir Common.dotWaspDirInWaspProjectDir Common.generatedCodeDirInDotWaspDir + waspProjectDir dotWaspDirInWaspProjectDir generatedCodeDirInDotWaspDir appSpec <- analyze waspProjectDir diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs index 708f2c31ff..09db8ca458 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs @@ -10,16 +10,16 @@ import StrongPath (()) import Wasp.Cli.Command (Command) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Common import Wasp.Generator.DbGenerator.Jobs (runStudio) import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed) import qualified Wasp.Message as Msg +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) studio :: Command () studio = do InWaspProject waspProjectDir <- require let genProjectDir = - waspProjectDir Common.dotWaspDirInWaspProjectDir Common.generatedCodeDirInDotWaspDir + waspProjectDir dotWaspDirInWaspProjectDir generatedCodeDirInDotWaspDir cliSendMessageC $ Msg.Start "Running studio..." diff --git a/waspc/cli/src/Wasp/Cli/Command/Require.hs b/waspc/cli/src/Wasp/Cli/Command/Require.hs index a640c7d64b..f4d072893c 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Require.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Require.hs @@ -37,9 +37,9 @@ import qualified StrongPath as SP import System.Directory (doesFileExist, doesPathExist, getCurrentDirectory) import qualified System.FilePath as FP import Wasp.Cli.Command (CommandError (CommandError), Requirable (checkRequirement), require) -import Wasp.Cli.Common (WaspProjectDir) -import qualified Wasp.Cli.Common as Cli.Common import Wasp.Generator.DbGenerator.Operations (isDbConnectionPossible, testDbConnection) +import Wasp.Project.Common (WaspProjectDir) +import qualified Wasp.Project.Common as Project.Common data DbConnectionEstablished = DbConnectionEstablished deriving (Typeable) @@ -48,7 +48,10 @@ instance Requirable DbConnectionEstablished where -- NOTE: 'InWaspProject' does not depend on this requirement, so this -- call to 'require' will not result in an infinite loop. InWaspProject waspProjectDir <- require - let outDir = waspProjectDir SP. Cli.Common.dotWaspDirInWaspProjectDir SP. Cli.Common.generatedCodeDirInDotWaspDir + let outDir = + waspProjectDir + SP. Project.Common.dotWaspDirInWaspProjectDir + SP. Project.Common.generatedCodeDirInDotWaspDir dbIsRunning <- liftIO $ isDbConnectionPossible <$> testDbConnection outDir if dbIsRunning @@ -82,7 +85,7 @@ instance Requirable InWaspProject where let absCurrentDirFp = SP.fromAbsDir currentDir doesCurrentDirExist <- liftIO $ doesPathExist absCurrentDirFp unless doesCurrentDirExist (throwError notFoundError) - let dotWaspRootFilePath = absCurrentDirFp FP. SP.fromRelFile Cli.Common.dotWaspRootFileInWaspProjectDir + let dotWaspRootFilePath = absCurrentDirFp FP. SP.fromRelFile Project.Common.dotWaspRootFileInWaspProjectDir isCurrentDirRoot <- liftIO $ doesFileExist dotWaspRootFilePath if isCurrentDirRoot then return $ InWaspProject $ SP.castDir currentDir diff --git a/waspc/cli/src/Wasp/Cli/Command/Start.hs b/waspc/cli/src/Wasp/Cli/Command/Start.hs index b1cc33f385..eb0f99536a 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Start.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Start.hs @@ -14,17 +14,17 @@ import Wasp.Cli.Command.Compile (compile, printWarningsAndErrorsIfAny) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (DbConnectionEstablished (DbConnectionEstablished), InWaspProject (InWaspProject), require) import Wasp.Cli.Command.Watch (watch) -import qualified Wasp.Cli.Common as Common import qualified Wasp.Generator import qualified Wasp.Message as Msg import Wasp.Project (CompileError, CompileWarning) +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) -- | Does initial compile of wasp code and then runs the generated project. -- It also listens for any file changes and recompiles and restarts generated project accordingly. start :: Command () start = do InWaspProject waspRoot <- require - let outDir = waspRoot Common.dotWaspDirInWaspProjectDir Common.generatedCodeDirInDotWaspDir + let outDir = waspRoot dotWaspDirInWaspProjectDir generatedCodeDirInDotWaspDir cliSendMessageC $ Msg.Start "Starting compilation and setup phase. Hold tight..." diff --git a/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs b/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs index eb2b3baaa5..7807418264 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Start/Db.hs @@ -22,8 +22,8 @@ import Wasp.Cli.Command.Common (throwIfExeIsNotAvailable) import Wasp.Cli.Command.Compile (analyze) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import Wasp.Cli.Common (WaspProjectDir) import qualified Wasp.Message as Msg +import Wasp.Project.Common (WaspProjectDir) import Wasp.Project.Db (databaseUrlEnvVarName) import Wasp.Project.Db.Dev (makeDevDbUniqueId) import qualified Wasp.Project.Db.Dev.Postgres as Dev.Postgres diff --git a/waspc/cli/src/Wasp/Cli/Command/Studio.hs b/waspc/cli/src/Wasp/Cli/Command/Studio.hs index 48ec59d4cd..f3929496bb 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Studio.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Studio.hs @@ -29,8 +29,8 @@ import Wasp.Cli.Command (Command, CommandError (CommandError)) import Wasp.Cli.Command.Compile (analyze) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) -import qualified Wasp.Cli.Common as Common import qualified Wasp.Message as Msg +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) import qualified Wasp.Project.Studio studio :: Command () @@ -123,8 +123,8 @@ studio = do ] let generatedProjectDir = - waspDir Common.dotWaspDirInWaspProjectDir - Common.generatedCodeDirInDotWaspDir + waspDir dotWaspDirInWaspProjectDir + generatedCodeDirInDotWaspDir let waspStudioDataJsonFilePath = generatedProjectDir [relfile|.wasp-studio-data.json|] liftIO $ do diff --git a/waspc/cli/src/Wasp/Cli/Command/Test.hs b/waspc/cli/src/Wasp/Cli/Command/Test.hs index 2e8a761aa5..e9378bfbce 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Test.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Test.hs @@ -14,10 +14,10 @@ import Wasp.Cli.Command.Compile (compile) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require (InWaspProject (InWaspProject), require) import Wasp.Cli.Command.Watch (watch) -import qualified Wasp.Cli.Common as Common import qualified Wasp.Generator import Wasp.Generator.Common (ProjectRootDir) import qualified Wasp.Message as Msg +import Wasp.Project.Common (dotWaspDirInWaspProjectDir, generatedCodeDirInDotWaspDir) test :: [String] -> Command () test [] = throwError $ CommandError "Not enough arguments" "Expected: wasp test client " @@ -28,7 +28,7 @@ test _ = throwError $ CommandError "Invalid arguments" "Expected: wasp test clie watchAndTest :: (Path' Abs (Dir ProjectRootDir) -> IO (Either String ())) -> Command () watchAndTest testRunner = do InWaspProject waspRoot <- require - let outDir = waspRoot Common.dotWaspDirInWaspProjectDir Common.generatedCodeDirInDotWaspDir + let outDir = waspRoot dotWaspDirInWaspProjectDir generatedCodeDirInDotWaspDir cliSendMessageC $ Msg.Start "Starting compilation and setup phase. Hold tight..." diff --git a/waspc/cli/src/Wasp/Cli/Command/Watch.hs b/waspc/cli/src/Wasp/Cli/Command/Watch.hs index 5689e0be56..2da3e77d19 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Watch.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Watch.hs @@ -15,11 +15,11 @@ import qualified StrongPath as SP import qualified System.FSNotify as FSN import qualified System.FilePath as FP import Wasp.Cli.Command.Compile (compileIO, printCompilationResult) -import qualified Wasp.Cli.Common as Common import Wasp.Cli.Message (cliSendMessage) import qualified Wasp.Generator.Common as Wasp.Generator import qualified Wasp.Message as Msg import Wasp.Project (CompileError, CompileWarning, WaspProjectDir) +import qualified Wasp.Project.Common as ProjectCommon -- TODO: Idea: Read .gitignore file, and ignore everything from it. This will then also cover the -- .wasp dir, and users can easily add any custom stuff they want ignored. But, we also have to @@ -41,9 +41,9 @@ watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mg chan <- newChan _ <- FSN.watchDirChan mgr (SP.fromAbsDir waspProjectDir) eventFilter chan let watchProjectSubdirTree path = FSN.watchTreeChan mgr (SP.fromAbsDir $ waspProjectDir path) eventFilter chan - _ <- watchProjectSubdirTree Common.extClientCodeDirInWaspProjectDir - _ <- watchProjectSubdirTree Common.extServerCodeDirInWaspProjectDir - _ <- watchProjectSubdirTree Common.extSharedCodeDirInWaspProjectDir + -- todo(filip): check if this still works + _ <- watchProjectSubdirTree ProjectCommon.extCodeDirInWaspProjectDir + _ <- watchProjectSubdirTree ProjectCommon.extPublicDirInWaspProjectDir listenForEvents chan currentTime where listenForEvents :: Chan FSN.Event -> UTCTime -> IO () diff --git a/waspc/cli/src/Wasp/Cli/Common.hs b/waspc/cli/src/Wasp/Cli/Common.hs index 282cf40dfe..2fe6841615 100644 --- a/waspc/cli/src/Wasp/Cli/Common.hs +++ b/waspc/cli/src/Wasp/Cli/Common.hs @@ -1,57 +1,15 @@ module Wasp.Cli.Common - ( WaspProjectDir, - DotWaspDir, - CliTemplatesDir, - dotWaspDirInWaspProjectDir, - dotWaspRootFileInWaspProjectDir, - dotWaspInfoFileInGeneratedCodeDir, - extServerCodeDirInWaspProjectDir, - extClientCodeDirInWaspProjectDir, - extSharedCodeDirInWaspProjectDir, - generatedCodeDirInDotWaspDir, - buildDirInDotWaspDir, + ( CliTemplatesDir, waspSays, waspWarns, waspScreams, ) where -import StrongPath (Dir, File', Path', Rel, reldir, relfile) -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) -import qualified Wasp.Generator.Common -import Wasp.Project (WaspProjectDir) import qualified Wasp.Util.Terminal as Term -data DotWaspDir -- Here we put everything that wasp generates. - data CliTemplatesDir --- TODO: SHould this be renamed to include word "root"? -dotWaspDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir DotWaspDir) -dotWaspDirInWaspProjectDir = [reldir|.wasp|] - --- TODO: Hm this has different name than it has in Generator. -generatedCodeDirInDotWaspDir :: Path' (Rel DotWaspDir) (Dir Wasp.Generator.Common.ProjectRootDir) -generatedCodeDirInDotWaspDir = [reldir|out|] - -buildDirInDotWaspDir :: Path' (Rel DotWaspDir) (Dir Wasp.Generator.Common.ProjectRootDir) -buildDirInDotWaspDir = [reldir|build|] - -dotWaspRootFileInWaspProjectDir :: Path' (Rel WaspProjectDir) File' -dotWaspRootFileInWaspProjectDir = [relfile|.wasproot|] - -dotWaspInfoFileInGeneratedCodeDir :: Path' (Rel Wasp.Generator.Common.ProjectRootDir) File' -dotWaspInfoFileInGeneratedCodeDir = [relfile|.waspinfo|] - -extServerCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir) -extServerCodeDirInWaspProjectDir = [reldir|src/server|] - -extClientCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir) -extClientCodeDirInWaspProjectDir = [reldir|src/client|] - -extSharedCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir) -extSharedCodeDirInWaspProjectDir = [reldir|src/shared|] - waspSays :: String -> IO () waspSays what = putStrLn $ Term.applyStyles [Term.Yellow] what diff --git a/waspc/data/Cli/templates/basic/package.json b/waspc/data/Cli/templates/basic/package.json new file mode 100644 index 0000000000..f6d2cca6fa --- /dev/null +++ b/waspc/data/Cli/templates/basic/package.json @@ -0,0 +1,28 @@ +{ + "name": "prototype", + "dependencies": { + "wasp": "file:.wasp/out/sdk/wasp", + "react": "^18.2.0" + }, + "devDependencies": { + "typescript": "^5.1.0", + "vite": "^4.3.9", + "@types/react": "^18.0.37", + "prisma": "4.16.2" + }, + "workspaces": [ + ".wasp/out/sdk/wasp", + ".wasp/out/web-app", + ".wasp/out/server" + ], + "//": [ + "COMMENTS:", + { + "devDependencies.prisma": [ + "We on purpose specify exact version for prisma.", + "Check this GH comment for the reasoning behind it:", + "https://github.com/wasp-lang/wasp/pull/634#issuecomment-1158802302 ." + ] + } + ] +} \ No newline at end of file diff --git a/waspc/data/Generator/templates/Dockerfile b/waspc/data/Generator/templates/Dockerfile index 8194f2916f..23833aabee 100644 --- a/waspc/data/Generator/templates/Dockerfile +++ b/waspc/data/Generator/templates/Dockerfile @@ -26,7 +26,7 @@ COPY server/ ./server/ RUN cd server && npm install {=# usingPrisma =} COPY db/schema.prisma ./db/ -RUN cd server && {= serverPrismaClientOutputDirEnv =} npx prisma generate --schema='{= dbSchemaFileFromServerDir =}' +RUN cd server && npx prisma generate --schema='{= dbSchemaFileFromServerDir =}' {=/ usingPrisma =} # Building the server should come after Prisma generation. RUN cd server && npm run build diff --git a/waspc/data/Generator/templates/db/schema.prisma b/waspc/data/Generator/templates/db/schema.prisma index ababaed1fe..d56db09e21 100644 --- a/waspc/data/Generator/templates/db/schema.prisma +++ b/waspc/data/Generator/templates/db/schema.prisma @@ -10,7 +10,6 @@ datasource db { generator client { provider = "prisma-client-js" - output = {=& prismaClientOutputDir =} {=# prismaPreviewFeatures =} previewFeatures = {=& . =} {=/ prismaPreviewFeatures =} diff --git a/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs b/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs index 27d6a9fd59..18ee507c9e 100644 --- a/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs +++ b/waspc/data/Generator/templates/react-app/scripts/validate-env.mjs @@ -1,4 +1,4 @@ -import { throwIfNotValidAbsoluteURL } from './universal/validators.mjs'; +import { throwIfNotValidAbsoluteURL } from 'wasp/universal/validators'; console.info("🔍 Validating environment variables..."); throwIfNotValidAbsoluteURL(process.env.REACT_APP_API_URL, 'Environemnt variable REACT_APP_API_URL'); diff --git a/waspc/data/Generator/templates/react-app/src/actions/index.ts b/waspc/data/Generator/templates/react-app/src/actions/index.ts index 5e4dfedd12..7fb2de2f9e 100644 --- a/waspc/data/Generator/templates/react-app/src/actions/index.ts +++ b/waspc/data/Generator/templates/react-app/src/actions/index.ts @@ -42,7 +42,7 @@ export type UpdateQuery = (item: ActionInput, oldData: /** * A public query specifier used for addressing Wasp queries. See our docs for details: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates */ export type QuerySpecifier = [Query, ...any[]] @@ -116,7 +116,7 @@ type InternalAction = Action & { * * @param publicOptimisticUpdateDefinition An optimistic update definition * object that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns An internally-used optimistic update definition object. */ function translateToInternalDefinition( @@ -260,7 +260,7 @@ function getOptimisticUpdateDefinitionForSpecificItem( * Translates a Wasp query specifier to a query cache key used by React Query. * * @param querySpecifier A query specifier that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns A cache key React Query internally uses for addressing queries. */ function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier): QueryKey { diff --git a/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts b/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts index c287486ef5..dafb5d9ac9 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/email/actions/login.ts @@ -5,7 +5,7 @@ import { initSession } from '../../helpers/user'; export async function login(data: { email: string; password: string }): Promise { try { const response = await api.post('{= loginPath =}', data); - await initSession(response.data.token); + await initSession(response.data.sessionId); } catch (e) { handleApiError(e); } diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx index 9ec80aa6f1..8fd6348c58 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx @@ -166,12 +166,6 @@ export const LoginSignupForm = ({ onLoginSuccess() { history.push('{= onAuthSucceededRedirectTo =}') }, - {=# isEmailVerificationRequired =} - isEmailVerificationRequired: true, - {=/ isEmailVerificationRequired =} - {=^ isEmailVerificationRequired =} - isEmailVerificationRequired: false, - {=/ isEmailVerificationRequired =} }); {=/ isEmailAuthEnabled =} {=# isAnyPasswordBasedAuthEnabled =} diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts index f5f4e371c0..4d8b792ba0 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts @@ -4,7 +4,6 @@ import { login } from '../../../email/actions/login' export function useEmail({ onError, showEmailVerificationPending, - isEmailVerificationRequired, onLoginSuccess, isLogin, }: { @@ -12,7 +11,6 @@ export function useEmail({ showEmailVerificationPending: () => void onLoginSuccess: () => void isLogin: boolean - isEmailVerificationRequired: boolean }) { async function handleSubmit(data) { try { @@ -21,12 +19,7 @@ export function useEmail({ onLoginSuccess() } else { await signup(data) - if (isEmailVerificationRequired) { - showEmailVerificationPending() - } else { - await login(data) - onLoginSuccess() - } + showEmailVerificationPending() } } catch (err: unknown) { onError(err as Error) diff --git a/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts b/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts index 1c6fc500f4..a6b06299ce 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/helpers/user.ts @@ -1,8 +1,8 @@ -import { setAuthToken } from '../../api' +import { setSessionId } from '../../api' import { invalidateAndRemoveQueries } from '../../operations/resources' -export async function initSession(token: string): Promise { - setAuthToken(token) +export async function initSession(sessionId: string): Promise { + setSessionId(sessionId) // We need to invalidate queries after login in order to get the correct user // data in the React components (using `useAuth`). // Redirects after login won't work properly without this. diff --git a/waspc/data/Generator/templates/react-app/src/auth/login.ts b/waspc/data/Generator/templates/react-app/src/auth/login.ts index 9bc9ceef53..aa808309f3 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/login.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/login.ts @@ -7,7 +7,7 @@ export default async function login(username: string, password: string): Promise const args = { username, password } const response = await api.post('{= loginPath =}', args) - await initSession(response.data.token) + await initSession(response.data.sessionId) } catch (error) { handleApiError(error) } diff --git a/waspc/data/Generator/templates/react-app/src/auth/logout.ts b/waspc/data/Generator/templates/react-app/src/auth/logout.ts index 44b9e05c33..715f99a49b 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/logout.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/logout.ts @@ -1,9 +1,17 @@ -import { removeLocalUserData } from '../api' +import api, { removeLocalUserData } from '../api' import { invalidateAndRemoveQueries } from '../operations/resources' export default async function logout(): Promise { - removeLocalUserData() - // TODO(filip): We are currently invalidating and removing all the queries, but - // we should remove only the non-public, user-dependent ones. - await invalidateAndRemoveQueries() + try { + await api.post('/auth/logout') + } finally { + // Even if the logout request fails, we still want to remove the local user data + // in case the logout failed because of a network error and the user walked away + // from the computer. + removeLocalUserData() + + // TODO(filip): We are currently invalidating and removing all the queries, but + // we should remove only the non-public, user-dependent ones. + await invalidateAndRemoveQueries() + } } diff --git a/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx index fdc0fbbcc0..10b3ed4d44 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx +++ b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.jsx @@ -29,7 +29,7 @@ export default function OAuthCodeExchange({ pathToApiServerRouteHandlingOauthRed // This helps us reuse one component for various methods (e.g., Google, Facebook, etc.). const apiServerUrlHandlingOauthRedirect = constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect) - exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) + exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect) return () => { firstRender.current = false } @@ -47,22 +47,22 @@ function constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRed return `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}` } -async function exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) { - const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect) +async function exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect) { + const sessionId = await exchangeCodeForSessionId(apiServerUrlHandlingOauthRedirect) - if (token !== null) { - await initSession(token) + if (sessionId !== null) { + await initSession(sessionId) history.push('{= onAuthSucceededRedirectTo =}') } else { - console.error('Error obtaining JWT token') + console.error('Error obtaining session ID') history.push('{= onAuthFailedRedirectTo =}') } } -async function exchangeCodeForJwt(url) { +async function exchangeCodeForSessionId(url) { try { const response = await api.get(url) - return response?.data?.token || null + return response?.data?.sessionId || null } catch (e) { console.error(e) return null diff --git a/waspc/data/Generator/templates/react-app/src/auth/types.ts b/waspc/data/Generator/templates/react-app/src/auth/types.ts index 4405410cc7..637a2e13d4 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/types.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/types.ts @@ -1,2 +1,2 @@ // todo(filip): turn into a proper import/path -export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../../../server/src/_types/' +export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../../../server/src/_types/' diff --git a/waspc/data/Generator/templates/react-app/src/auth/user.ts b/waspc/data/Generator/templates/react-app/src/auth/user.ts index 5799c71ea7..aa0da24824 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/user.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/user.ts @@ -2,7 +2,7 @@ // We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts // If you are changing the logic here, make sure to change it there as well. -import type { User, ProviderName, DeserializedAuthEntity } from './types' +import type { User, ProviderName, DeserializedAuthIdentity } from './types' export function getEmail(user: User): string | null { return findUserIdentity(user, "email")?.providerUserId ?? null; @@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null { return user.auth.identities[0].providerUserId ?? null; } -export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined { return user.auth.identities.find( (identity) => identity.providerName === providerName ); diff --git a/waspc/data/Generator/templates/react-app/src/index.tsx b/waspc/data/Generator/templates/react-app/src/index.tsx index dc0c1171ed..da29899438 100644 --- a/waspc/data/Generator/templates/react-app/src/index.tsx +++ b/waspc/data/Generator/templates/react-app/src/index.tsx @@ -7,7 +7,7 @@ import router from './router' import { initializeQueryClient, queryClientInitialized, -} from './queryClient' +} from 'wasp/rpc/queryClient' {=# setupFn.isDefined =} {=& setupFn.importStatement =} diff --git a/waspc/data/Generator/templates/react-app/src/queries/core.d.ts b/waspc/data/Generator/templates/react-app/src/queries/core.d.ts index e1bdbe4783..90e30187a9 100644 --- a/waspc/data/Generator/templates/react-app/src/queries/core.d.ts +++ b/waspc/data/Generator/templates/react-app/src/queries/core.d.ts @@ -1,6 +1,6 @@ import { type Query } from '.' import { Route } from '../types'; -import type { Expand, _Awaited, _ReturnType } from '../universal/types' +import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types' export function createQuery( queryRoute: string, diff --git a/waspc/data/Generator/templates/react-app/src/router.tsx b/waspc/data/Generator/templates/react-app/src/router.tsx index 1c290df6d0..ed1de164a0 100644 --- a/waspc/data/Generator/templates/react-app/src/router.tsx +++ b/waspc/data/Generator/templates/react-app/src/router.tsx @@ -12,7 +12,7 @@ import type { {=/ rootComponent.isDefined =} {=# isAuthEnabled =} -import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage" +import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage" {=/ isAuthEnabled =} {=# pagesToImport =} diff --git a/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx b/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx index 10e4aa1e96..ef6e4b1e2a 100644 --- a/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx +++ b/waspc/data/Generator/templates/react-app/src/webSocket/WebSocketProvider.tsx @@ -2,7 +2,7 @@ import { createContext, useState, useEffect } from 'react' import { io, Socket } from 'socket.io-client' -import { getAuthToken } from '../api' +import { getSessionId } from '../api' import { apiEventsEmitter } from '../api/events' import config from '../config' @@ -16,7 +16,7 @@ function refreshAuthToken() { // NOTE: When we figure out how `auth: true` works for Operations, we should // mirror that behavior here for WebSockets. Ref: https://github.com/wasp-lang/wasp/issues/1133 socket.auth = { - token: getAuthToken() + sessionId: getSessionId() } if (socket.connected) { @@ -26,8 +26,8 @@ function refreshAuthToken() { } refreshAuthToken() -apiEventsEmitter.on('authToken.set', refreshAuthToken) -apiEventsEmitter.on('authToken.clear', refreshAuthToken) +apiEventsEmitter.on('sessionId.set', refreshAuthToken) +apiEventsEmitter.on('sessionId.clear', refreshAuthToken) export const WebSocketContext = createContext({ socket, diff --git a/waspc/data/Generator/templates/react-app/tsconfig.json b/waspc/data/Generator/templates/react-app/tsconfig.json index 968a1bb47f..263338d1ce 100644 --- a/waspc/data/Generator/templates/react-app/tsconfig.json +++ b/waspc/data/Generator/templates/react-app/tsconfig.json @@ -8,6 +8,12 @@ // Allow importing pages with the .tsx extension. "allowImportingTsExtensions": true }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/waspc/data/Generator/templates/react-app/vite.config.ts b/waspc/data/Generator/templates/react-app/vite.config.ts index 31e1055ddb..8bc0157ed7 100644 --- a/waspc/data/Generator/templates/react-app/vite.config.ts +++ b/waspc/data/Generator/templates/react-app/vite.config.ts @@ -1,7 +1,7 @@ {{={= =}=}} /// import { mergeConfig } from "vite"; -import react from "@vitejs/plugin-react-swc"; +import react from "@vitejs/plugin-react"; {=# customViteConfig.isDefined =} {=& customViteConfig.importStatement =} @@ -14,6 +14,9 @@ const _waspUserProvidedConfig = {}; const defaultViteConfig = { base: "{= baseDir =}", plugins: [react()], + optimizeDeps: { + exclude: ['wasp'] + }, server: { port: {= defaultClientPort =}, host: "0.0.0.0", @@ -27,6 +30,9 @@ const defaultViteConfig = { environment: "jsdom", setupFiles: ["./src/test/vitest/setup.ts"], }, + // resolve: { + // dedupe: ["react", "react-dom"], + // }, }; // https://vitejs.dev/config/ diff --git a/waspc/data/Generator/templates/react-app/src/api/events.ts b/waspc/data/Generator/templates/sdk/api/events.ts similarity index 58% rename from waspc/data/Generator/templates/react-app/src/api/events.ts rename to waspc/data/Generator/templates/sdk/api/events.ts index 9a59b366d3..a72e48dda8 100644 --- a/waspc/data/Generator/templates/react-app/src/api/events.ts +++ b/waspc/data/Generator/templates/sdk/api/events.ts @@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt'; type ApiEvents = { // key: Event name // type: Event payload type - 'authToken.set': void; - 'authToken.clear': void; + 'sessionId.set': void; + 'sessionId.clear': void; }; -// Used to allow API clients to register for auth token change events. +// Used to allow API clients to register for auth session ID change events. export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/data/Generator/templates/react-app/src/api.ts b/waspc/data/Generator/templates/sdk/api/index.ts similarity index 64% rename from waspc/data/Generator/templates/react-app/src/api.ts rename to waspc/data/Generator/templates/sdk/api/index.ts index d7532f65c6..8b22dd7ebc 100644 --- a/waspc/data/Generator/templates/react-app/src/api.ts +++ b/waspc/data/Generator/templates/sdk/api/index.ts @@ -1,66 +1,67 @@ import axios, { type AxiosError } from 'axios' -import config from './config' -import { storage } from './storage' -import { apiEventsEmitter } from './api/events' +import config from 'wasp/core/config' +import { storage } from 'wasp/core/storage' +import { apiEventsEmitter } from 'wasp/api/events' const api = axios.create({ baseURL: config.apiUrl, }) -const WASP_APP_AUTH_TOKEN_NAME = 'authToken' +const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId' -let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined +let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined -export function setAuthToken(token: string): void { - authToken = token - storage.set(WASP_APP_AUTH_TOKEN_NAME, token) - apiEventsEmitter.emit('authToken.set') +export function setSessionId(sessionId: string): void { + waspAppAuthSessionId = sessionId + storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId) + apiEventsEmitter.emit('sessionId.set') } -export function getAuthToken(): string | undefined { - return authToken +export function getSessionId(): string | undefined { + return waspAppAuthSessionId } -export function clearAuthToken(): void { - authToken = undefined - storage.remove(WASP_APP_AUTH_TOKEN_NAME) - apiEventsEmitter.emit('authToken.clear') +export function clearSessionId(): void { + waspAppAuthSessionId = undefined + storage.remove(WASP_APP_AUTH_SESSION_ID_NAME) + apiEventsEmitter.emit('sessionId.clear') } export function removeLocalUserData(): void { - authToken = undefined + waspAppAuthSessionId = undefined storage.clear() - apiEventsEmitter.emit('authToken.clear') + apiEventsEmitter.emit('sessionId.clear') } api.interceptors.request.use((request) => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` + const sessionId = getSessionId() + if (sessionId) { + request.headers['Authorization'] = `Bearer ${sessionId}` } return request }) api.interceptors.response.use(undefined, (error) => { if (error.response?.status === 401) { - clearAuthToken() + clearSessionId() } return Promise.reject(error) }) // This handler will run on other tabs (not the active one calling API functions), -// and will ensure they know about auth token changes. +// and will ensure they know about auth session ID changes. // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event // "Note: This won't work on the same page that is making the changes — it is really a way // for other pages on the domain using the storage to sync any changes that are made." window.addEventListener('storage', (event) => { - if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) { + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { if (!!event.newValue) { - authToken = event.newValue - apiEventsEmitter.emit('authToken.set') + waspAppAuthSessionId = event.newValue + apiEventsEmitter.emit('sessionId.set') } else { - authToken = undefined - apiEventsEmitter.emit('authToken.clear') + waspAppAuthSessionId = undefined + apiEventsEmitter.emit('sessionId.clear') } } }) diff --git a/waspc/data/Generator/templates/sdk/auth/forms/Auth.tsx b/waspc/data/Generator/templates/sdk/auth/forms/Auth.tsx new file mode 100644 index 0000000000..92c58131f6 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/Auth.tsx @@ -0,0 +1,85 @@ +import { useState, createContext } from 'react' +import { createTheme } from '@stitches/react' +import { styled } from 'wasp/core/stitches.config' + +import { + type State, + type CustomizationOptions, + type ErrorMessage, + type AdditionalSignupFields, +} from './types' +import { LoginSignupForm } from './internal/common/LoginSignupForm' +import { MessageError, MessageSuccess } from './internal/Message' + +const logoStyle = { + height: '3rem' +} + +const Container = styled('div', { + display: 'flex', + flexDirection: 'column', +}) + +const HeaderText = styled('h2', { + fontSize: '1.875rem', + fontWeight: '700', + marginTop: '1.5rem' +}) + + +export const AuthContext = createContext({ + isLoading: false, + setIsLoading: (isLoading: boolean) => {}, + setErrorMessage: (errorMessage: ErrorMessage | null) => {}, + setSuccessMessage: (successMessage: string | null) => {}, +}) + +function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: { + state: State; +} & CustomizationOptions & { + additionalSignupFields?: AdditionalSignupFields; +}) { + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // TODO(matija): this is called on every render, is it a problem? + // If we do it in useEffect(), then there is a glitch between the default color and the + // user provided one. + const customTheme = createTheme(appearance ?? {}) + + const titles: Record = { + login: 'Log in to your account', + signup: 'Create a new account', + } + const title = titles[state] + + const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal' + + return ( + +
+ {logo && (Your Company)} + {title} +
+ + {errorMessage && ( + + {errorMessage.title}{errorMessage.description && ': '}{errorMessage.description} + + )} + {successMessage && {successMessage}} + + {(state === 'login' || state === 'signup') && ( + + )} + +
+ ) +} + +export default Auth; diff --git a/waspc/data/Generator/templates/sdk/auth/forms/Login.tsx b/waspc/data/Generator/templates/sdk/auth/forms/Login.tsx new file mode 100644 index 0000000000..2ea532d9c5 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/Login.tsx @@ -0,0 +1,17 @@ +import Auth from './Auth' +import { type CustomizationOptions, State } from './types' + +export function LoginForm({ + appearance, + logo, + socialLayout, +}: CustomizationOptions) { + return ( + + ) +} diff --git a/waspc/data/Generator/templates/sdk/auth/forms/Signup.tsx b/waspc/data/Generator/templates/sdk/auth/forms/Signup.tsx new file mode 100644 index 0000000000..66ffab4503 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/Signup.tsx @@ -0,0 +1,23 @@ +import Auth from './Auth' +import { + type CustomizationOptions, + type AdditionalSignupFields, + State, +} from './types' + +export function SignupForm({ + appearance, + logo, + socialLayout, + additionalFields, +}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) { + return ( + + ) +} diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/Form.tsx b/waspc/data/Generator/templates/sdk/auth/forms/internal/Form.tsx new file mode 100644 index 0000000000..781c75a0ae --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/Form.tsx @@ -0,0 +1,95 @@ +import { styled } from 'wasp/core/stitches.config' + +export const Form = styled('form', { + marginTop: '1.5rem', +}) + +export const FormItemGroup = styled('div', { + '& + div': { + marginTop: '1.5rem', + }, +}) + +export const FormLabel = styled('label', { + display: 'block', + fontSize: '$sm', + fontWeight: '500', + marginBottom: '0.5rem', +}) + +const commonInputStyles = { + display: 'block', + lineHeight: '1.5rem', + fontSize: '$sm', + borderWidth: '1px', + borderColor: '$gray600', + backgroundColor: '#f8f4ff', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + '&:focus': { + borderWidth: '1px', + borderColor: '$gray700', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + }, + '&:disabled': { + opacity: 0.5, + cursor: 'not-allowed', + backgroundColor: '$gray400', + borderColor: '$gray400', + color: '$gray500', + }, + + borderRadius: '0.375rem', + width: '100%', + + paddingTop: '0.375rem', + paddingBottom: '0.375rem', + paddingLeft: '0.75rem', + paddingRight: '0.75rem', + margin: 0, +} + +export const FormInput = styled('input', commonInputStyles) + +export const FormTextarea = styled('textarea', commonInputStyles) + +export const FormError = styled('div', { + display: 'block', + fontSize: '$sm', + fontWeight: '500', + color: '$formErrorText', + marginTop: '0.5rem', +}) + +export const SubmitButton = styled('button', { + display: 'flex', + justifyContent: 'center', + + width: '100%', + borderWidth: '1px', + borderColor: '$brand', + backgroundColor: '$brand', + color: '$submitButtonText', + + padding: '0.5rem 0.75rem', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + + fontWeight: '600', + fontSize: '$sm', + lineHeight: '1.25rem', + borderRadius: '0.375rem', + + // TODO(matija): extract this into separate BaseButton component and then inherit it. + '&:hover': { + backgroundColor: '$brandAccent', + borderColor: '$brandAccent', + }, + '&:disabled': { + opacity: 0.5, + cursor: 'not-allowed', + backgroundColor: '$gray400', + borderColor: '$gray400', + color: '$gray500', + }, + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionDuration: '100ms', +}) diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/Message.tsx b/waspc/data/Generator/templates/sdk/auth/forms/internal/Message.tsx new file mode 100644 index 0000000000..7279ed2525 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/Message.tsx @@ -0,0 +1,18 @@ +import { styled } from 'wasp/core/stitches.config' + +export const Message = styled('div', { + padding: '0.5rem 0.75rem', + borderRadius: '0.375rem', + marginTop: '1rem', + background: '$gray400', +}) + +export const MessageError = styled(Message, { + background: '$errorBackground', + color: '$errorText', +}) + +export const MessageSuccess = styled(Message, { + background: '$successBackground', + color: '$successText', +}) diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/sdk/auth/forms/internal/common/LoginSignupForm.tsx new file mode 100644 index 0000000000..30665b4759 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/common/LoginSignupForm.tsx @@ -0,0 +1,178 @@ +import { useContext } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import { styled } from 'wasp/core/stitches.config' +import config from 'wasp/core/config' + +import { AuthContext } from '../../Auth' +import { + Form, + FormInput, + FormItemGroup, + FormLabel, + FormError, + FormTextarea, + SubmitButton, +} from '../Form' +import type { + AdditionalSignupFields, + AdditionalSignupField, + AdditionalSignupFieldRenderFn, + FormState, +} from '../../types' +import { useHistory } from 'react-router-dom' +import { useUsernameAndPassword } from '../usernameAndPassword/useUsernameAndPassword' + + +export type LoginSignupFormFields = { + [key: string]: string; +} + +export const LoginSignupForm = ({ + state, + socialButtonsDirection = 'horizontal', + additionalSignupFields, +}: { + state: 'login' | 'signup' + socialButtonsDirection?: 'horizontal' | 'vertical' + additionalSignupFields?: AdditionalSignupFields +}) => { + const { + isLoading, + setErrorMessage, + setSuccessMessage, + setIsLoading, + } = useContext(AuthContext) + const isLogin = state === 'login' + const cta = isLogin ? 'Log in' : 'Sign up'; + const history = useHistory(); + const onErrorHandler = (error) => { + setErrorMessage({ title: error.message, description: error.data?.data?.message }) + }; + const hookForm = useForm() + const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm + const { handleSubmit } = useUsernameAndPassword({ + isLogin, + onError: onErrorHandler, + onSuccess() { + history.push('/') + }, + }); + async function onSubmit (data) { + setIsLoading(true); + setErrorMessage(null); + setSuccessMessage(null); + try { + await handleSubmit(data); + } finally { + setIsLoading(false); + } + } + + return (<> +
+ + Username + + {errors.username && {errors.username.message}} + + + Password + + {errors.password && {errors.password.message}} + + + + {cta} + + + ) +} + +function AdditionalFormFields({ + hookForm, + formState: { isLoading }, + additionalSignupFields, +}: { + hookForm: UseFormReturn; + formState: FormState; + additionalSignupFields: AdditionalSignupFields; +}) { + const { + register, + formState: { errors }, + } = hookForm; + + function renderField>( + field: AdditionalSignupField, + // Ideally we would use ComponentType here, but it doesn't work with react-hook-form + Component: any, + props?: React.ComponentProps + ) { + return ( + + {field.label} + + {errors[field.name] && ( + {errors[field.name].message} + )} + + ); + } + + if (areAdditionalFieldsRenderFn(additionalSignupFields)) { + return additionalSignupFields(hookForm, { isLoading }) + } + + return ( + additionalSignupFields && + additionalSignupFields.map((field) => { + if (isFieldRenderFn(field)) { + return field(hookForm, { isLoading }) + } + switch (field.type) { + case 'input': + return renderField(field, FormInput, { + type: 'text', + }) + case 'textarea': + return renderField(field, FormTextarea) + default: + throw new Error( + `Unsupported additional signup field type: ${field.type}` + ) + } + }) + ) +} + +function isFieldRenderFn( + additionalSignupField: AdditionalSignupField | AdditionalSignupFieldRenderFn +): additionalSignupField is AdditionalSignupFieldRenderFn { + return typeof additionalSignupField === 'function' +} + +function areAdditionalFieldsRenderFn( + additionalSignupFields: AdditionalSignupFields +): additionalSignupFields is AdditionalSignupFieldRenderFn { + return typeof additionalSignupFields === 'function' +} diff --git a/waspc/data/Generator/templates/sdk/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts b/waspc/data/Generator/templates/sdk/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts new file mode 100644 index 0000000000..247c1faeb4 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts @@ -0,0 +1,29 @@ +import signup from '../../../signup' +import login from '../../../login' + +export function useUsernameAndPassword({ + onError, + onSuccess, + isLogin, +}: { + onError: (error: Error) => void + onSuccess: () => void + isLogin: boolean +}) { + async function handleSubmit(data) { + try { + if (!isLogin) { + await signup(data) + } + await login(data.username, data.password) + + onSuccess() + } catch (err: unknown) { + onError(err as Error) + } + } + + return { + handleSubmit, + } +} diff --git a/waspc/data/Generator/templates/sdk/auth/forms/types.ts b/waspc/data/Generator/templates/sdk/auth/forms/types.ts new file mode 100644 index 0000000000..14d61ad51e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/forms/types.ts @@ -0,0 +1,39 @@ +import { createTheme } from '@stitches/react' +import { UseFormReturn, RegisterOptions } from 'react-hook-form' +import type { LoginSignupFormFields } from './internal/common/LoginSignupForm' + +export enum State { + Login = 'login', + Signup = 'signup', +} + +export type CustomizationOptions = { + logo?: string + socialLayout?: 'horizontal' | 'vertical' + appearance?: Parameters[0] +} + +export type ErrorMessage = { + title: string + description?: string +} + +export type FormState = { + isLoading: boolean +} + +export type AdditionalSignupFieldRenderFn = ( + hookForm: UseFormReturn, + formState: FormState +) => React.ReactNode + +export type AdditionalSignupField = { + name: string + label: string + type: 'input' | 'textarea' + validations?: RegisterOptions +} + +export type AdditionalSignupFields = + | (AdditionalSignupField | AdditionalSignupFieldRenderFn)[] + | AdditionalSignupFieldRenderFn diff --git a/waspc/data/Generator/templates/sdk/auth/helpers/user.ts b/waspc/data/Generator/templates/sdk/auth/helpers/user.ts new file mode 100644 index 0000000000..498f2588a8 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/helpers/user.ts @@ -0,0 +1,14 @@ +import { setSessionId } from 'wasp/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export async function initSession(sessionId: string): Promise { + setSessionId(sessionId) + // We need to invalidate queries after login in order to get the correct user + // data in the React components (using `useAuth`). + // Redirects after login won't work properly without this. + + // TODO(filip): We are currently removing all the queries, but we should + // remove only non-public, user-dependent queries - public queries are + // expected not to change in respect to the currently logged in user. + await invalidateAndRemoveQueries() +} diff --git a/waspc/data/Generator/templates/sdk/auth/jwt.ts b/waspc/data/Generator/templates/sdk/auth/jwt.ts new file mode 100644 index 0000000000..b244990158 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/jwt.ts @@ -0,0 +1,12 @@ +import jwt from 'jsonwebtoken' +import util from 'util' + +import config from 'wasp/server/config' + +const jwtSign = util.promisify(jwt.sign) +const jwtVerify = util.promisify(jwt.verify) + +const JWT_SECRET = config.auth.jwtSecret + +export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) +export const verify = (token) => jwtVerify(token, JWT_SECRET) diff --git a/waspc/data/Generator/templates/sdk/auth/login.ts b/waspc/data/Generator/templates/sdk/auth/login.ts new file mode 100644 index 0000000000..2b4ec4b9fe --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/login.ts @@ -0,0 +1,13 @@ +import api, { handleApiError } from 'wasp/api' +import { initSession } from './helpers/user' + +export default async function login(username: string, password: string): Promise { + try { + const args = { username, password } + const response = await api.post('/auth/username/login', args) + + await initSession(response.data.sessionId) + } catch (error) { + handleApiError(error) + } +} diff --git a/waspc/data/Generator/templates/sdk/auth/logout.ts b/waspc/data/Generator/templates/sdk/auth/logout.ts new file mode 100644 index 0000000000..cc41b6989c --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/logout.ts @@ -0,0 +1,17 @@ +import api, { removeLocalUserData } from 'wasp/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export default async function logout(): Promise { + try { + await api.post('/auth/logout') + } finally { + // Even if the logout request fails, we still want to remove the local user data + // in case the logout failed because of a network error and the user walked away + // from the computer. + removeLocalUserData() + + // TODO(filip): We are currently invalidating and removing all the queries, but + // we should remove only the non-public, user-dependent ones. + await invalidateAndRemoveQueries() + } +} diff --git a/waspc/data/Generator/templates/sdk/auth/lucia.ts b/waspc/data/Generator/templates/sdk/auth/lucia.ts new file mode 100644 index 0000000000..168fdf4a4e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/lucia.ts @@ -0,0 +1,54 @@ +import { Lucia } from "lucia"; +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; +import prisma from '../server/dbClient.js' +import { type User } from "../entities/index.js" + +const prismaAdapter = new PrismaAdapter( + // Using `as any` here since Lucia's model types are not compatible with Prisma 4 + // model types. This is a temporary workaround until we migrate to Prisma 5. + // This **works** in runtime, but Typescript complains about it. + prisma.session as any, + prisma.auth as any +); + +/** + * We are using Lucia for session management. + * + * Some details: + * 1. We are using the Prisma adapter for Lucia. + * 2. We are not using cookies for session management. Instead, we are using + * the Authorization header to send the session token. + * 3. Our `Session` entity is connected to the `Auth` entity. + * 4. We are exposing the `userId` field from the `Auth` entity to + * make fetching the User easier. + */ +export const auth = new Lucia<{}, { + userId: User['id'] +}>(prismaAdapter, { + // Since we are not using cookies, we don't need to set any cookie options. + // But in the future, if we decide to use cookies, we can set them here. + + // sessionCookie: { + // name: "session", + // expires: true, + // attributes: { + // secure: !config.isDevelopment, + // sameSite: "lax", + // }, + // }, + getUserAttributes({ userId }) { + return { + userId, + }; + }, +}); + +declare module "lucia" { + interface Register { + Lucia: typeof auth; + DatabaseSessionAttributes: {}; + DatabaseUserAttributes: { + userId: User['id'] + }; + } +} diff --git a/waspc/data/Generator/templates/sdk/auth/pages/createAuthRequiredPage.jsx b/waspc/data/Generator/templates/sdk/auth/pages/createAuthRequiredPage.jsx new file mode 100644 index 0000000000..621ef393d9 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/pages/createAuthRequiredPage.jsx @@ -0,0 +1,30 @@ +import React from 'react' + +import { Redirect } from 'react-router-dom' +import useAuth from '../useAuth' + + +const createAuthRequiredPage = (Page) => { + return (props) => { + const { data: user, isError, isSuccess, isLoading } = useAuth() + + if (isSuccess) { + if (user) { + return ( + + ) + } else { + return + } + } else if (isLoading) { + return Loading... + } else if (isError) { + return An error ocurred. Please refresh the page. + } else { + return An unknown error ocurred. Please refresh the page. + } + } +} + +export default createAuthRequiredPage + diff --git a/waspc/data/Generator/templates/sdk/auth/password.ts b/waspc/data/Generator/templates/sdk/auth/password.ts new file mode 100644 index 0000000000..a359892b5e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/password.ts @@ -0,0 +1,15 @@ +import SecurePassword from 'secure-password' + +const SP = new SecurePassword() + +export const hashPassword = async (password: string): Promise => { + const hashedPwdBuffer = await SP.hash(Buffer.from(password)) + return hashedPwdBuffer.toString("base64") +} + +export const verifyPassword = async (hashedPassword: string, password: string): Promise => { + const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) + if (result !== SecurePassword.VALID) { + throw new Error('Invalid password.') + } +} diff --git a/waspc/data/Generator/templates/sdk/auth/providers/types.ts b/waspc/data/Generator/templates/sdk/auth/providers/types.ts new file mode 100644 index 0000000000..76e1114850 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/providers/types.ts @@ -0,0 +1,40 @@ +import type { Router, Request } from 'express' +import type { Prisma } from '@prisma/client' +import type { Expand } from 'wasp/universal/types' +import type { ProviderName } from '../utils' + +type UserEntityCreateInput = Prisma.UserCreateInput + +export type ProviderConfig = { + // Unique provider identifier, used as part of URL paths + id: ProviderName; + displayName: string; + // Each provider config can have an init method which is ran on setup time + // e.g. for oAuth providers this is the time when the Passport strategy is registered. + init?(provider: ProviderConfig): Promise; + // Every provider must have a setupRouter method which returns the Express router. + // In this function we are flexibile to do what ever is necessary to make the provider work. + createRouter(provider: ProviderConfig, initData: InitData): Router; +}; + +export type InitData = { + [key: string]: any; +} + +export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } + +export type PossibleUserFields = Expand> + +export type UserSignupFields = { + [key in keyof PossibleUserFields]: FieldGetter< + PossibleUserFields[key] + > +} + +type FieldGetter = ( + data: { [key: string]: unknown } +) => Promise | T | undefined + +export function defineUserSignupFields(fields: UserSignupFields) { + return fields +} diff --git a/waspc/data/Generator/templates/sdk/auth/session.ts b/waspc/data/Generator/templates/sdk/auth/session.ts new file mode 100644 index 0000000000..0d1590674c --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/session.ts @@ -0,0 +1,107 @@ +import { Request as ExpressRequest } from "express"; + +import { type User } from "wasp/entities" +import { type SanitizedUser } from 'wasp/server/_types/index.js' + +import { auth } from "./lucia.js"; +import type { Session } from "lucia"; +import { + throwInvalidCredentialsError, + deserializeAndSanitizeProviderData, +} from "./utils.js"; + +import prisma from 'wasp/server/dbClient' + +// Creates a new session for the `authId` in the database +export async function createSession(authId: string): Promise { + return auth.createSession(authId, {}); +} + +export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const authorizationHeader = req.headers["authorization"]; + + if (typeof authorizationHeader !== "string") { + return { + user: null, + session: null, + }; + } + + const sessionId = auth.readBearerToken(authorizationHeader); + if (!sessionId) { + return { + user: null, + session: null, + }; + } + + return getSessionAndUserFromSessionId(sessionId); +} + +export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const { session, user: authEntity } = await auth.validateSession(sessionId); + + if (!session || !authEntity) { + return { + user: null, + session: null, + }; + } + + return { + session, + user: await getUser(authEntity.userId) + } +} + +async function getUser(userId: User['id']): Promise { + const user = await prisma.user + .findUnique({ + where: { id: userId }, + include: { + auth: { + include: { + identities: true + } + } + } + }) + + if (!user) { + throwInvalidCredentialsError() + } + + // TODO: This logic must match the type in _types/index.ts (if we remove the + // password field from the object here, we must to do the same there). + // Ideally, these two things would live in the same place: + // https://github.com/wasp-lang/wasp/issues/965 + const deserializedIdentities = user.auth.identities.map((identity) => { + const deserializedProviderData = deserializeAndSanitizeProviderData( + identity.providerData, + { + shouldRemovePasswordField: true, + } + ) + return { + ...identity, + providerData: deserializedProviderData, + } + }) + return { + ...user, + auth: { + ...user.auth, + identities: deserializedIdentities, + }, + } +} + +export function invalidateSession(sessionId: string): Promise { + return auth.invalidateSession(sessionId); +} diff --git a/waspc/data/Generator/templates/sdk/auth/signup.ts b/waspc/data/Generator/templates/sdk/auth/signup.ts new file mode 100644 index 0000000000..bde50c5ebd --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/signup.ts @@ -0,0 +1,9 @@ +import api, { handleApiError } from 'wasp/api' + +export default async function signup(userFields: { username: string; password: string }): Promise { + try { + await api.post('/auth/username/signup', userFields) + } catch (error) { + handleApiError(error) + } +} diff --git a/waspc/data/Generator/templates/sdk/auth/types.ts b/waspc/data/Generator/templates/sdk/auth/types.ts new file mode 100644 index 0000000000..f9f079a57a --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/types.ts @@ -0,0 +1,2 @@ +// todo(filip): turn into a proper import/path +export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/' diff --git a/waspc/data/Generator/templates/sdk/auth/useAuth.ts b/waspc/data/Generator/templates/sdk/auth/useAuth.ts new file mode 100644 index 0000000000..29b95f62a0 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/useAuth.ts @@ -0,0 +1,38 @@ +import { deserialize as superjsonDeserialize } from 'superjson' +import { useQuery } from 'wasp/rpc' +import api, { handleApiError } from 'wasp/api' +import { HttpMethod } from 'wasp/types' +import type { User } from './types' +import { addMetadataToQuery } from 'wasp/rpc/queries' + +export const getMe = createUserGetter() + +export default function useAuth(queryFnArgs?: unknown, config?: any) { + return useQuery(getMe, queryFnArgs, config) +} + +function createUserGetter() { + const getMeRelativePath = 'auth/me' + const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` } + async function getMe(): Promise { + try { + const response = await api.get(getMeRoute.path) + + return superjsonDeserialize(response.data) + } catch (error) { + if (error.response?.status === 401) { + return null + } else { + handleApiError(error) + } + } + } + + addMetadataToQuery(getMe, { + relativeQueryPath: getMeRelativePath, + queryRoute: getMeRoute, + entitiesUsed: ['User'], + }) + + return getMe +} diff --git a/waspc/data/Generator/templates/sdk/auth/user.ts b/waspc/data/Generator/templates/sdk/auth/user.ts new file mode 100644 index 0000000000..aa0da24824 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/user.ts @@ -0,0 +1,27 @@ +// We decided not to deduplicate these helper functions in the server and the client. +// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts +// If you are changing the logic here, make sure to change it there as well. + +import type { User, ProviderName, DeserializedAuthIdentity } from './types' + +export function getEmail(user: User): string | null { + return findUserIdentity(user, "email")?.providerUserId ?? null; +} + +export function getUsername(user: User): string | null { + return findUserIdentity(user, "username")?.providerUserId ?? null; +} + +export function getFirstProviderUserId(user?: User): string | null { + if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) { + return null; + } + + return user.auth.identities[0].providerUserId ?? null; +} + +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined { + return user.auth.identities.find( + (identity) => identity.providerName === providerName + ); +} diff --git a/waspc/data/Generator/templates/sdk/auth/utils.ts b/waspc/data/Generator/templates/sdk/auth/utils.ts new file mode 100644 index 0000000000..15f8531261 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/utils.ts @@ -0,0 +1,302 @@ +import { hashPassword } from './password.js' +import { verify } from './jwt.js' +import AuthError from 'wasp/core/AuthError' +import HttpError from 'wasp/core/HttpError' +import prisma from 'wasp/server/dbClient' +import { sleep } from 'wasp/server/utils' +import { + type User, + type Auth, + type AuthIdentity, +} from 'wasp/entities' +import { Prisma } from '@prisma/client'; + +import { throwValidationError } from './validation.js' + +import { type UserSignupFields, type PossibleUserFields } from './providers/types.js' + +export type EmailProviderData = { + hashedPassword: string; + isEmailVerified: boolean; + emailVerificationSentAt: string | null; + passwordResetSentAt: string | null; +} + +export type UsernameProviderData = { + hashedPassword: string; +} + +export type OAuthProviderData = {} + +/** + * This type is used for type-level programming e.g. to enumerate + * all possible provider data types. + * + * The keys of this type are the names of the providers and the values + * are the types of the provider data. + */ +export type PossibleProviderData = { + email: EmailProviderData; + username: UsernameProviderData; + google: OAuthProviderData; + github: OAuthProviderData; +} + +export type ProviderName = keyof PossibleProviderData + +export const contextWithUserEntity = { + entities: { + User: prisma.user + } +} + +export const authConfig = { + failureRedirectPath: "/login", + successRedirectPath: "/", +} + +/** + * ProviderId uniquely identifies an auth identity e.g. + * "email" provider with user id "test@test.com" or + * "google" provider with user id "1234567890". + * + * We use this type to avoid passing the providerName and providerUserId + * separately. Also, we can normalize the providerUserId to make sure it's + * consistent across different DB operations. + */ +export type ProviderId = { + providerName: ProviderName; + providerUserId: string; +} + +export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId { + return { + providerName, + providerUserId: providerUserId.toLowerCase(), + } +} + +export async function findAuthIdentity(providerId: ProviderId): Promise { + return prisma.authIdentity.findUnique({ + where: { + providerName_providerUserId: providerId, + } + }); +} + +/** + * Updates the provider data for the given auth identity. + * + * This function performs data sanitization and serialization. + * Sanitization is done by hashing the password, so this function + * expects the password received in the `providerDataUpdates` + * **not to be hashed**. + */ +export async function updateAuthIdentityProviderData( + providerId: ProviderId, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, +): Promise { + // We are doing the sanitization here only on updates to avoid + // hashing the password multiple times. + const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates); + const newProviderData = { + ...existingProviderData, + ...sanitizedProviderDataUpdates, + } + const serializedProviderData = await serializeProviderData(newProviderData); + return prisma.authIdentity.update({ + where: { + providerName_providerUserId: providerId, + }, + data: { providerData: serializedProviderData }, + }); +} + +type FindAuthWithUserResult = Auth & { + user: User +} + +export async function findAuthWithUserBy( + where: Prisma.AuthWhereInput +): Promise { + return prisma.auth.findFirst({ where, include: { user: true }}); +} + +export async function createUser( + providerId: ProviderId, + serializedProviderData?: string, + userFields?: PossibleUserFields, +): Promise { + return prisma.user.create({ + data: { + // Using any here to prevent type errors when userFields are not + // defined. We want Prisma to throw an error in that case. + ...(userFields ?? {} as any), + auth: { + create: { + identities: { + create: { + providerName: providerId.providerName, + providerUserId: providerId.providerUserId, + providerData: serializedProviderData, + }, + }, + } + }, + }, + // We need to include the Auth entity here because we need `authId` + // to be able to create a session. + include: { + auth: true, + }, + }) +} + +export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> { + return prisma.user.deleteMany({ where: { auth: { + id: authId, + } } }) +} + +export async function verifyToken(token: string): Promise { + return verify(token); +} + +// If an user exists, we don't want to leak information +// about it. Pretending that we're doing some work +// will make it harder for an attacker to determine +// if a user exists or not. +// NOTE: Attacker measuring time to response can still determine +// if a user exists or not. We'll be able to avoid it when +// we implement e-mail sending via jobs. +export async function doFakeWork(): Promise { + const timeToWork = Math.floor(Math.random() * 1000) + 1000; + return sleep(timeToWork); +} + +export function rethrowPossibleAuthError(e: unknown): void { + if (e instanceof AuthError) { + throwValidationError((e as any).message); + } + + // Prisma code P2002 is for unique constraint violations. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpError(422, 'Save failed', { + message: `user with the same identity already exists`, + }) + } + + if (e instanceof Prisma.PrismaClientValidationError) { + // NOTE: Logging the error since this usually means that there are + // required fields missing in the request, we want the developer + // to know about it. + console.error(e) + throw new HttpError(422, 'Save failed', { + message: 'there was a database error' + }) + } + + // Prisma code P2021 is for missing table errors. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') { + // NOTE: Logging the error since this usually means that the database + // migrations weren't run, we want the developer to know about it. + console.error(e) + console.info('🐝 This error can happen if you did\'t run the database migrations.') + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + // Prisma code P2003 is for foreign key constraint failure + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') { + console.error(e) + console.info(`🐝 This error can happen if you have some relation on your User entity + but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull". + Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`) + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + throw e +} + +export async function validateAndGetUserFields( + data: { + [key: string]: unknown + }, + userSignupFields?: UserSignupFields, +): Promise> { + const { + password: _password, + ...sanitizedData + } = data; + const result: Record = {}; + + if (!userSignupFields) { + return result; + } + + for (const [field, getFieldValue] of Object.entries(userSignupFields)) { + try { + const value = await getFieldValue(sanitizedData) + result[field] = value + } catch (e) { + throwValidationError(e.message) + } + } + return result; +} + +export function deserializeAndSanitizeProviderData( + providerData: string, + { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {}, +): PossibleProviderData[PN] { + // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON. + let data = JSON.parse(providerData) as PossibleProviderData[PN]; + + if (providerDataHasPasswordField(data) && shouldRemovePasswordField) { + delete data.hashedPassword; + } + + return data; +} + +export async function sanitizeAndSerializeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + return serializeProviderData( + await sanitizeProviderData(providerData) + ); +} + +function serializeProviderData(providerData: PossibleProviderData[PN]): string { + return JSON.stringify(providerData); +} + +async function sanitizeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + const data = { + ...providerData, + }; + if (providerDataHasPasswordField(data)) { + data.hashedPassword = await hashPassword(data.hashedPassword); + } + + return data; +} + + +function providerDataHasPasswordField( + providerData: PossibleProviderData[keyof PossibleProviderData], +): providerData is { hashedPassword: string } { + return 'hashedPassword' in providerData; +} + +export function throwInvalidCredentialsError(message?: string): void { + throw new HttpError(401, 'Invalid credentials', { message }) +} diff --git a/waspc/data/Generator/templates/sdk/auth/validation.ts b/waspc/data/Generator/templates/sdk/auth/validation.ts new file mode 100644 index 0000000000..73bac13e21 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/auth/validation.ts @@ -0,0 +1,77 @@ +import HttpError from 'wasp/core/HttpError'; + +export const PASSWORD_FIELD = 'password'; +const USERNAME_FIELD = 'username'; +const EMAIL_FIELD = 'email'; +const TOKEN_FIELD = 'token'; + +export function ensureValidEmail(args: unknown): void { + validate(args, [ + { validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email }, + { validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) }, + ]); +} + +export function ensureValidUsername(args: unknown): void { + validate(args, [ + { validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username } + ]); +} + +export function ensurePasswordIsPresent(args: unknown): void { + validate(args, [ + { validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password }, + ]); +} + +export function ensureValidPassword(args: unknown): void { + validate(args, [ + { validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => isMinLength(password, 8) }, + { validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => containsNumber(password) }, + ]); +} + +export function ensureTokenIsPresent(args: unknown): void { + validate(args, [ + { validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token }, + ]); +} + +export function throwValidationError(message: string): void { + throw new HttpError(422, 'Validation failed', { message }) +} + +function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void { + for (const { validates, message, validator } of validators) { + if (!validator(args[validates])) { + throwValidationError(message); + } + } +} + +// NOTE(miho): it would be good to replace our custom validations with e.g. Zod + +const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ +function isValidEmail(input: unknown): boolean { + if (typeof input !== 'string') { + return false + } + + return input.match(validEmailRegex) !== null +} + +function isMinLength(input: unknown, minLength: number): boolean { + if (typeof input !== 'string') { + return false + } + + return input.length >= minLength +} + +function containsNumber(input: unknown): boolean { + if (typeof input !== 'string') { + return false + } + + return /\d/.test(input) +} diff --git a/waspc/data/Generator/templates/server/src/core/AuthError.js b/waspc/data/Generator/templates/sdk/core/AuthError.js similarity index 100% rename from waspc/data/Generator/templates/server/src/core/AuthError.js rename to waspc/data/Generator/templates/sdk/core/AuthError.js diff --git a/waspc/data/Generator/templates/server/src/core/HttpError.js b/waspc/data/Generator/templates/sdk/core/HttpError.js similarity index 100% rename from waspc/data/Generator/templates/server/src/core/HttpError.js rename to waspc/data/Generator/templates/sdk/core/HttpError.js diff --git a/waspc/data/Generator/templates/sdk/core/auth.js b/waspc/data/Generator/templates/sdk/core/auth.js new file mode 100644 index 0000000000..2408af794c --- /dev/null +++ b/waspc/data/Generator/templates/sdk/core/auth.js @@ -0,0 +1,38 @@ +import { handleRejection } from 'wasp/server/utils' +import { getSessionAndUserFromBearerToken } from 'wasp/auth/session' +import { throwInvalidCredentialsError } from 'wasp/auth/utils' + +/** + * Auth middleware + * + * If the request includes an `Authorization` header it will try to authenticate the request, + * otherwise it will let the request through. + * + * - If authentication succeeds it sets `req.sessionId` and `req.user` + * - `req.user` is the user that made the request and it's used in + * all Wasp features that need to know the user that made the request. + * - `req.sessionId` is the ID of the session that authenticated the request. + * - If the request is not authenticated, it throws an error. + */ +const auth = handleRejection(async (req, res, next) => { + const authHeader = req.get('Authorization') + if (!authHeader) { + // NOTE(matija): for now we let tokenless requests through and make it operation's + // responsibility to verify whether the request is authenticated or not. In the future + // we will develop our own system at Wasp-level for that. + return next() + } + + const { session, user } = await getSessionAndUserFromBearerToken(req); + + if (!session || !user) { + throwInvalidCredentialsError() + } + + req.sessionId = session.id + req.user = user + + next() +}) + +export default auth diff --git a/waspc/data/Generator/templates/sdk/core/config.js b/waspc/data/Generator/templates/sdk/core/config.js new file mode 100644 index 0000000000..e9234e6f2a --- /dev/null +++ b/waspc/data/Generator/templates/sdk/core/config.js @@ -0,0 +1,9 @@ +import { stripTrailingSlash } from 'wasp/universal/url' + +const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || 'http://localhost:3001'; + +const config = { + apiUrl, +} + +export default config diff --git a/waspc/data/Generator/templates/sdk/core/stitches.config.js b/waspc/data/Generator/templates/sdk/core/stitches.config.js new file mode 100644 index 0000000000..c1d600a3f6 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/core/stitches.config.js @@ -0,0 +1,33 @@ +import { createStitches } from '@stitches/react' + +export const { + styled, + css +} = createStitches({ + theme: { + colors: { + waspYellow: '#ffcc00', + gray700: '#a1a5ab', + gray600: '#d1d5db', + gray500: 'gainsboro', + gray400: '#f0f0f0', + red: '#FED7D7', + darkRed: '#fa3838', + green: '#C6F6D5', + + brand: '$waspYellow', + brandAccent: '#ffdb46', + errorBackground: '$red', + errorText: '#2D3748', + successBackground: '$green', + successText: '#2D3748', + + submitButtonText: 'black', + + formErrorText: '$darkRed', + }, + fontSizes: { + sm: '0.875rem' + } + } +}) diff --git a/waspc/data/Generator/templates/sdk/core/storage.ts b/waspc/data/Generator/templates/sdk/core/storage.ts new file mode 100644 index 0000000000..0321acea8b --- /dev/null +++ b/waspc/data/Generator/templates/sdk/core/storage.ts @@ -0,0 +1,50 @@ +export type DataStore = { + getPrefixedKey(key: string): string + set(key: string, value: unknown): void + get(key: string): unknown + remove(key: string): void + clear(): void +} + +function createLocalStorageDataStore(prefix: string): DataStore { + function getPrefixedKey(key: string): string { + return `${prefix}:${key}` + } + + return { + getPrefixedKey, + set(key, value) { + ensureLocalStorageIsAvailable() + localStorage.setItem(getPrefixedKey(key), JSON.stringify(value)) + }, + get(key) { + ensureLocalStorageIsAvailable() + const value = localStorage.getItem(getPrefixedKey(key)) + try { + return value ? JSON.parse(value) : undefined + } catch (e: any) { + return undefined + } + }, + remove(key) { + ensureLocalStorageIsAvailable() + localStorage.removeItem(getPrefixedKey(key)) + }, + clear() { + ensureLocalStorageIsAvailable() + Object.keys(localStorage).forEach((key) => { + if (key.startsWith(prefix)) { + localStorage.removeItem(key) + } + }) + }, + } +} + +export const storage = createLocalStorageDataStore('wasp') + +function ensureLocalStorageIsAvailable(): void { + if (!window.localStorage) { + throw new Error('Local storage is not available.') + } +} diff --git a/waspc/data/Generator/templates/sdk/dependencies.txt b/waspc/data/Generator/templates/sdk/dependencies.txt new file mode 100644 index 0000000000..56e1643232 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/dependencies.txt @@ -0,0 +1,132 @@ +Dependencies: + +("@prisma/client", show prismaVersion), // sdk +("@tanstack/react-query", "^4.29.0"), // sdk +("axios", "^1.4.0"), // sdk +("cookie-parser", "~1.4.6"), // +("cors", "^2.8.5"), // +("dotenv", "16.0.2"), // +("express", "~4.18.1"), // sdk (for types) +("helmet", "^6.0.0"), // +("jsonwebtoken", "^8.5.1"), // sdk +("lodash.merge", "^4.6.2"), // +("mitt", "3.0.0"), // sdk +("morgan", "~1.10.0"), // +("patch-package", "^6.4.7"), // +("rate-limiter-flexible", "^2.4.1"), // +("react", "^18.2.0"), // sdk +("react-dom", "^18.2.0"), // +("react-hook-form", "^7.45.4") // +("react-router-dom", "^5.3.3"), // sdk +("secure-password", "^4.0.0"), // sdk +("superjson", "^1.12.2"), // sdk +("uuid", "^9.0.0"), // + +Dev dependencies: +("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"), +("@tsconfig/vite-react", "^2.0.0") +("@types/cors", "^2.8.5") +("@types/express", "^4.17.13"), +("@types/express-serve-static-core", "^4.17.13"), +("@types/node", "^18.11.9"), +("@types/react", "^18.0.37"), +("@types/react-dom", "^18.0.11"), +("@types/react-router-dom", "^5.3.3"), +("@types/uuid", "^9.0.0"), +("@vitejs/plugin-react-swc", "^3.0.0"), +("dotenv", "^16.0.3"), // duplicate +("nodemon", "^2.0.19"), // +("prisma", show prismaVersion), // +("standard", "^17.0.0"), // +("typescript", "^5.1.0"), // +("vite", "^4.3.9"), // + +Their package.json: +("react", "^18.2.0"), +("typescript", "^5.1.0") + + +Server + +("cookie-parser", "~1.4.6"), +- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts + +("cors", "^2.8.5"), +- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts + +("express", "~4.18.1"), +- Generator/templates/server/src/auth/providers/config/local.ts +- Generator/templates/server/src/auth/providers/config/email.ts +- Generator/templates/server/src/routes/crud/index.ts +- Generator/templates/server/src/routes/crud/_crud.ts +- Generator/templates/server/src/routes/operations/index.js +- Generator/templates/server/src/routes/index.js +- Generator/templates/server/src/auth/providers/index.ts +- Generator/templates/server/src/auth/providers/oauth/createRouter.ts +- Generator/templates/server/src/routes/apis/index.ts +- Generator/templates/server/src/auth/providers/types.ts +- Generator/templates/server/src/types/index.ts +- Generator/templates/server/src/middleware/globalMiddleware.ts +- Generator/templates/server/src/app.js +- Generator/templates/server/src/auth/providers/email/signup.ts +- Generator/templates/server/src/routes/auth/index.js +- Generator/templates/server/src/auth/providers/email/login.ts +- Generator/templates/server/src/auth/providers/email/resetPassword.ts +- Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts +- Generator/templates/server/src/auth/providers/email/verifyEmail.ts +- Generator/templates/server/src/_types/index.ts +- Generator/templates/server/src/apis/types.ts + +("morgan", "~1.10.0"), +- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts + +("@prisma/client", show prismaVersion), +- [SDK] Generator/templates/react-app/src/entities/index.ts +- [SDK] Generator/templates/server/src/dbClient.ts +- [Framework] Generator/templates/server/src/utils.js +- Generator/templates/server/src/auth/utils.ts +- Generator/templates/server/src/entities/index.ts +- Generator/templates/server/src/auth/providers/oauth/types.ts +- Generator/templates/server/src/crud/_operations.ts +- Generator/templates/server/src/dbSeed/types.ts + + +("jsonwebtoken", "^8.5.1"), +-- NOTE: secure-password has a package.json override for sodium-native. +("secure-password", "^4.0.0"), +("dotenv", "16.0.2"), +("helmet", "^6.0.0"), +("patch-package", "^6.4.7"), +("uuid", "^9.0.0"), +("lodash.merge", "^4.6.2"), +("rate-limiter-flexible", "^2.4.1"), +("superjson", "^1.12.2") + +depsRequiredByPassport spec + +depsRequiredByJobs spec + +depsRequiredByEmail spec + +depsRequiredByWebSockets spec, + N.waspDevDependencies = + AS.Dependency.fromList + [ ("nodemon", "^2.0.19"), + ("standard", "^17.0.0"), + ("prisma", show prismaVersion), + -- TODO: Allow users to choose whether they want to use TypeScript + -- in their projects and install these dependencies accordingly. + ("typescript", "^5.1.0"), + ("@types/express", "^4.17.13"), + ("@types/express-serve-static-core", "^4.17.13"), + ("@types/node", "^18.11.9"), + ("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"), + ("@types/uuid", "^9.0.0"), + ("@types/cors", "^2.8.5") + ] + } + + +LOG: +- react moved from web-app to project package.json +- react-dom moved from web-app to project package.json diff --git a/waspc/data/Generator/templates/server/src/entities/index.ts b/waspc/data/Generator/templates/sdk/entities/index.ts similarity index 100% rename from waspc/data/Generator/templates/server/src/entities/index.ts rename to waspc/data/Generator/templates/sdk/entities/index.ts diff --git a/waspc/data/Generator/templates/sdk/operations/index.ts b/waspc/data/Generator/templates/sdk/operations/index.ts new file mode 100644 index 0000000000..31e70ae98b --- /dev/null +++ b/waspc/data/Generator/templates/sdk/operations/index.ts @@ -0,0 +1,22 @@ +import api, { handleApiError } from 'wasp/api' +import { HttpMethod } from 'wasp/types' +import { + serialize as superjsonSerialize, + deserialize as superjsonDeserialize, +} from 'superjson' + +export type OperationRoute = { method: HttpMethod, path: string } + +export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) { + try { + const superjsonArgs = superjsonSerialize(args) + const response = await api.post(operationRoute.path, superjsonArgs) + return superjsonDeserialize(response.data) + } catch (error) { + handleApiError(error) + } +} + +export function makeOperationRoute(relativeOperationRoute: string): OperationRoute { + return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` } +} diff --git a/waspc/data/Generator/templates/sdk/operations/resources.js b/waspc/data/Generator/templates/sdk/operations/resources.js new file mode 100644 index 0000000000..5261654600 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/operations/resources.js @@ -0,0 +1,81 @@ +import { queryClientInitialized } from 'wasp/rpc/queryClient' +import { makeUpdateHandlersMap } from './updateHandlersMap' +import { hashQueryKey } from '@tanstack/react-query' + +// Map where key is resource name and value is Set +// containing query ids of all the queries that use +// that resource. +const resourceToQueryCacheKeys = new Map() + +const updateHandlers = makeUpdateHandlersMap(hashQueryKey) +/** + * Remembers that specified query is using specified resources. + * If called multiple times for same query, resources are added, not reset. + * @param {string[]} queryCacheKey - Unique key under used to identify query in the cache. + * @param {string[]} resources - Names of resources that query is using. + */ +export function addResourcesUsedByQuery(queryCacheKey, resources) { + for (const resource of resources) { + let cacheKeys = resourceToQueryCacheKeys.get(resource) + if (!cacheKeys) { + cacheKeys = new Set() + resourceToQueryCacheKeys.set(resource, cacheKeys) + } + cacheKeys.add(queryCacheKey) + } +} + +export function registerActionInProgress(optimisticUpdateTuples) { + optimisticUpdateTuples.forEach( + ({ queryKey, updateQuery }) => updateHandlers.add(queryKey, updateQuery) + ) +} + +export async function registerActionDone(resources, optimisticUpdateTuples) { + optimisticUpdateTuples.forEach(({ queryKey }) => updateHandlers.remove(queryKey)) + await invalidateQueriesUsing(resources) +} + +export function getActiveOptimisticUpdates(queryKey) { + return updateHandlers.getUpdateHandlers(queryKey) +} + +export async function invalidateAndRemoveQueries() { + const queryClient = await queryClientInitialized + // If we don't reset the queries before removing them, Wasp will stay on + // the same page. The user would have to manually refresh the page to "finish" + // logging out. + // When a query is removed, the `Observer` is removed as well, and the components + // that are using the query are not re-rendered. This is why we need to reset + // the queries, so that the `Observer` is re-created and the components are re-rendered. + // For more details: https://github.com/wasp-lang/wasp/pull/1014/files#r1111862125 + queryClient.resetQueries() + // If we don't remove the queries after invalidating them, the old query data + // remains in the cache, casuing a potential privacy issue. + queryClient.removeQueries() +} + +/** + * Invalidates all queries that are using specified resources. + * @param {string[]} resources - Names of resources. + */ +async function invalidateQueriesUsing(resources) { + const queryClient = await queryClientInitialized + + const queryCacheKeysToInvalidate = getQueriesUsingResources(resources) + queryCacheKeysToInvalidate.forEach( + queryCacheKey => queryClient.invalidateQueries(queryCacheKey) + ) +} + +/** + * @param {string} resource - Resource name. + * @returns {string[]} Array of "query cache keys" of queries that use specified resource. + */ +function getQueriesUsingResource(resource) { + return Array.from(resourceToQueryCacheKeys.get(resource) || []) +} + +function getQueriesUsingResources(resources) { + return Array.from(new Set(resources.flatMap(getQueriesUsingResource))) +} diff --git a/waspc/data/Generator/templates/sdk/operations/updateHandlersMap.js b/waspc/data/Generator/templates/sdk/operations/updateHandlersMap.js new file mode 100644 index 0000000000..8c43c0b1ba --- /dev/null +++ b/waspc/data/Generator/templates/sdk/operations/updateHandlersMap.js @@ -0,0 +1,37 @@ +export function makeUpdateHandlersMap(calculateHash) { + const updateHandlers = new Map() + + function getHandlerTuples(queryKeyHash) { + return updateHandlers.get(queryKeyHash) || []; + } + + function add(queryKey, updateQuery) { + const queryKeyHash = calculateHash(queryKey) + const handlers = getHandlerTuples(queryKeyHash); + updateHandlers.set(queryKeyHash, [...handlers, { queryKey, updateQuery }]) + } + + function getUpdateHandlers(queryKey) { + const queryKeyHash = calculateHash(queryKey) + return getHandlerTuples(queryKeyHash).map(({ updateQuery }) => updateQuery) + } + + function remove(queryKeyToRemove) { + const queryKeyHash = calculateHash(queryKeyToRemove) + const filteredHandlers = getHandlerTuples(queryKeyHash).filter( + ({ queryKey }) => queryKey !== queryKeyToRemove + ) + + if (filteredHandlers.length > 0) { + updateHandlers.set(queryKeyHash, filteredHandlers) + } else { + updateHandlers.delete(queryKeyHash) + } + } + + return { + add, + remove, + getUpdateHandlers, + } +} diff --git a/waspc/data/Generator/templates/sdk/package.json b/waspc/data/Generator/templates/sdk/package.json new file mode 100644 index 0000000000..d3e692dc5d --- /dev/null +++ b/waspc/data/Generator/templates/sdk/package.json @@ -0,0 +1,87 @@ +{{={= =}=}} +{ + "name": "wasp", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist" + }, + "exports": { + {=! todo(filip): Check all exports when done with SDK generation =} + {=! Some of the statements in the comments might become incorrect. =} + {=! "our code" means: "web-app", "server" or "SDK", or "some combination of the three". =} + {=! Used by users, documented. =} + "./core/HttpError": "./dist/core/HttpError.js", + "./core/AuthError": "./dist/core/AuthError.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./core/config": "./dist/core/config.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./core/stitches.config": "./dist/core/stitches.config.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./core/storage": "./dist/core/storage.js", + "./core/auth": "./dist/core/auth.js", + {=! Used by users, documented. =} + "./rpc": "./dist/rpc/index.js", + {=! Used by users, documented. =} + "./rpc/queries": "./dist/rpc/queries/index.js", + {=! Used by users, documented. =} + "./rpc/actions": "./dist/rpc/actions/index.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./rpc/queryClient": "./dist/rpc/queryClient.js", + {=! Used by users, documented. =} + "./types": "./dist/types/index.js", + {=! Used by users, documented. =} + "./auth/login": "./dist/auth/login.js", + {=! Used by users, documented. =} + "./auth/logout": "./dist/auth/logout.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./auth/user": "./dist/auth/user.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./auth/session": "./dist/auth/session.js", + {=! Not sure who uses this, ask Miho. Our code definitely does, I don't know about the users =} + "./auth/utils": "./dist/auth/utils.js", + {=! Used by users, documented. =} + "./auth/forms/Login": "./dist/auth/forms/Login.jsx", + {=! Used by users, documented. =} + "./auth/forms/Signup": "./dist/auth/forms/Signup.jsx", + {=! Not sure who uses this, ask Miho. Our code definitely does, I don't know about the users =} + "./auth/pages/createAuthRequiredPage": "./dist/auth/pages/createAuthRequiredPage.jsx", + {=! Used by users, documented. =} + "./api": "./dist/api/index.js", + {=! Parts are used by users, documented. Parts are probably used by our code, undocumented (but accessible). =} + "./api/*": "./dist/api/*", + {=! Used by users, documented. =} + "./operations": "./dist/operations/index.js", + {=! If we import a symbol like "import something form 'wasp/something'", we must =} + {=! expose it here (which leaks it to our users). We could avoid this by =} + {=! using relative imports inside SDK code (instead of library imports), =} + {=! but I didn't have time to implement it. =} + "./ext-src/*": "./dist/ext-src/*.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./operations/*": "./dist/operations/*", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./universal/url": "./dist/universal/url.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./universal/types": "./dist/universal/types.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./universal/validators": "./dist/universal/validators.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./server/dbClient": "./dist/server/dbClient.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./server/config": "./dist/server/config.js", + {=! Parts are used by users, documented. Parts are probably used by our code, undocumented (but accessible). =} + "./server/utils": "./dist/server/utils.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./server/actions": "./dist/server/actions/index.js", + {=! Used by our code, uncodumented (but accessible) for users. =} + "./server/queries": "./dist/server/queries/index.js" + }, + "license": "ISC", + "include": [ + "src/**/*" + ], + {=& depsChunk =}, + {=& devDepsChunk =} +} diff --git a/waspc/data/Generator/templates/sdk/rpc/actions/core.d.ts b/waspc/data/Generator/templates/sdk/rpc/actions/core.d.ts new file mode 100644 index 0000000000..ea41a0eed3 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/actions/core.d.ts @@ -0,0 +1,13 @@ +import { type Action } from '.' +import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types' + +export function createAction( + actionRoute: string, + entitiesUsed: unknown[] +): ActionFor + +type ActionFor = Expand< + Action[0], _Awaited<_ReturnType>> +> + +type GenericBackendAction = (args: never, context: any) => unknown diff --git a/waspc/data/Generator/templates/sdk/rpc/actions/core.js b/waspc/data/Generator/templates/sdk/rpc/actions/core.js new file mode 100644 index 0000000000..cd1c60ecef --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/actions/core.js @@ -0,0 +1,37 @@ +import { callOperation, makeOperationRoute } from 'wasp/operations' +import { + registerActionInProgress, + registerActionDone, +} from 'wasp/operations/resources' + +// todo(filip) - turn helpers and core into the same thing + +export function createAction(relativeActionRoute, entitiesUsed) { + const actionRoute = makeOperationRoute(relativeActionRoute) + + async function internalAction(args, specificOptimisticUpdateDefinitions) { + registerActionInProgress(specificOptimisticUpdateDefinitions) + try { + // The `return await` is not redundant here. If we removed the await, the + // `finally` block would execute before the action finishes, prematurely + // registering the action as done. + return await callOperation(actionRoute, args) + } finally { + await registerActionDone(entitiesUsed, specificOptimisticUpdateDefinitions) + } + } + + // We expose (and document) a restricted version of the API for our users, + // while also attaching the full "internal" API to the exposed action. By + // doing this, we can easily use the internal API of an action a users passes + // into our system (e.g., through the `useAction` hook) without needing a + // lookup table. + // + // While it does technically allow our users to access the interal API, it + // shouldn't be a problem in practice. Still, if it turns out to be a problem, + // we can always hide it using a Symbol. + const action = (args) => internalAction(args, []) + action.internal = internalAction + + return action +} diff --git a/waspc/data/Generator/templates/sdk/rpc/actions/index.ts b/waspc/data/Generator/templates/sdk/rpc/actions/index.ts new file mode 100644 index 0000000000..2be33b3d65 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/actions/index.ts @@ -0,0 +1,14 @@ +import { createAction } from './core' +import { CreateTask, UpdateTask } from 'wasp/server/actions' + +export const updateTask = createAction('operations/update-task', [ + 'Task', +]) + +export const createTask = createAction('operations/create-task', [ + 'Task', +]) + +export const deleteTasks = createAction('operations/delete-tasks', [ + 'Task', +]) diff --git a/waspc/data/Generator/templates/sdk/rpc/index.ts b/waspc/data/Generator/templates/sdk/rpc/index.ts new file mode 100644 index 0000000000..8a743e3456 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/index.ts @@ -0,0 +1,338 @@ +import { + QueryClient, + QueryKey, + useMutation, + UseMutationOptions, + useQueryClient, + useQuery as rqUseQuery, + UseQueryResult, +} from "@tanstack/react-query"; +export { configureQueryClient } from "./queryClient"; + +export type Query = { + (queryCacheKey: string[], args: Input): Promise; +}; + +export function useQuery( + queryFn: Query, + queryFnArgs?: Input, + options?: any +): UseQueryResult; + +export function useQuery(queryFn, queryFnArgs, options) { + if (typeof queryFn !== "function") { + throw new TypeError("useQuery requires queryFn to be a function."); + } + if (!queryFn.queryCacheKey) { + throw new TypeError( + "queryFn needs to have queryCacheKey property defined." + ); + } + + const queryKey = + queryFnArgs !== undefined + ? [...queryFn.queryCacheKey, queryFnArgs] + : queryFn.queryCacheKey; + return rqUseQuery({ + queryKey, + queryFn: () => queryFn(queryKey, queryFnArgs), + ...options, + }); +} + +// todo - turn helpers and core into the same thing + +export type Action = [Input] extends [never] + ? (args?: unknown) => Promise + : (args: Input) => Promise; + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + */ +export type ActionOptions = { + optimisticUpdates: OptimisticUpdateDefinition[]; +}; + +/** + * A documented (public) way to define optimistic updates. + */ +export type OptimisticUpdateDefinition = { + getQuerySpecifier: GetQuerySpecifier; + updateQuery: UpdateQuery; +}; + +/** + * A function that takes an item and returns a Wasp Query specifier. + */ +export type GetQuerySpecifier = ( + item: ActionInput +) => QuerySpecifier; + +/** + * A function that takes an item and the previous state of the cache, and returns + * the desired (new) state of the cache. + */ +export type UpdateQuery = ( + item: ActionInput, + oldData: CachedData | undefined +) => CachedData; + +/** + * A public query specifier used for addressing Wasp queries. See our docs for details: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + */ +export type QuerySpecifier = [Query, ...any[]]; + +/** + * A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates). + * + * @param actionFn The Wasp Action you wish to enhance/decorate. + * @param actionOptions An options object for enhancing/decorating the given Action. + * @returns A decorated Action with added behavior but an unchanged API. + */ +export function useAction( + actionFn: Action, + actionOptions?: ActionOptions +): typeof actionFn { + const queryClient = useQueryClient(); + + let mutationFn = actionFn; + let options = {}; + if (actionOptions?.optimisticUpdates) { + const optimisticUpdatesDefinitions = actionOptions.optimisticUpdates.map( + translateToInternalDefinition + ); + mutationFn = makeOptimisticUpdateMutationFn( + actionFn, + optimisticUpdatesDefinitions + ); + options = makeRqOptimisticUpdateOptions( + queryClient, + optimisticUpdatesDefinitions + ); + } + + // NOTE: We decided to hide React Query's extra mutation features (e.g., + // isLoading, onSuccess and onError callbacks, synchronous mutate) and only + // expose a simple async function whose API matches the original Action. + // We did this to avoid cluttering the API with stuff we're not sure we need + // yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to + // clearly separate our opinionated API from React Query's lower-level + // advanced API (which users can also use) + const mutation = useMutation(mutationFn, options); + return (args) => mutation.mutateAsync(args); +} + +/** + * An internal (undocumented, private, desugared) way of defining optimistic updates. + */ +type InternalOptimisticUpdateDefinition = { + getQueryKey: (item: ActionInput) => QueryKey; + updateQuery: UpdateQuery; +}; + +/** + * An UpdateQuery function "instantiated" with a specific item. It only takes + * the current state of the cache and returns the desired (new) state of the + * cache. + */ +type SpecificUpdateQuery = (oldData: CachedData) => CachedData; + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + */ +type SpecificOptimisticUpdateDefinition = { + queryKey: QueryKey; + updateQuery: SpecificUpdateQuery; +}; + +type InternalAction = Action & { + internal( + item: Input, + optimisticUpdateDefinitions: SpecificOptimisticUpdateDefinition[] + ): Promise; +}; + +/** + * Translates/Desugars a public optimistic update definition object into a + * definition object our system uses internally. + * + * @param publicOptimisticUpdateDefinition An optimistic update definition + * object that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns An internally-used optimistic update definition object. + */ +function translateToInternalDefinition( + publicOptimisticUpdateDefinition: OptimisticUpdateDefinition +): InternalOptimisticUpdateDefinition { + const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition; + + const definitionErrors = []; + if (typeof getQuerySpecifier !== "function") { + definitionErrors.push("`getQuerySpecifier` is not a function."); + } + if (typeof updateQuery !== "function") { + definitionErrors.push("`updateQuery` is not a function."); + } + if (definitionErrors.length) { + throw new TypeError( + `Invalid optimistic update definition: ${definitionErrors.join(", ")}.` + ); + } + + return { + getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)), + updateQuery, + }; +} + +/** + * Creates a function that performs an action while telling it about the + * optimistic updates it caused. + * + * @param actionFn The Wasp Action. + * @param optimisticUpdateDefinitions The optimisitc updates the action causes. + * @returns An decorated action which performs optimistic updates. + */ +function makeOptimisticUpdateMutationFn( + actionFn: Action, + optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition< + Input, + CachedData + >[] +): typeof actionFn { + return function performActionWithOptimisticUpdates(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + (generalDefinition) => + getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ); + return (actionFn as InternalAction).internal( + item, + specificOptimisticUpdateDefinitions + ); + }; +} + +/** + * Given a ReactQuery query client and our internal definition of optimistic + * updates, this function constructs an object describing those same optimistic + * updates in a format we can pass into React Query's useMutation hook. In other + * words, it translates our optimistic updates definition into React Query's + * optimistic updates definition. Check their docs for details: + * https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates + * + * @param queryClient The QueryClient instance used by React Query. + * @param optimisticUpdateDefinitions A list containing internal optimistic + * updates definition objects (i.e., a list where each object carries the + * instructions for performing particular optimistic update). + * @returns An object containing 'onMutate' and 'onError' functions + * corresponding to the given optimistic update definitions (check the docs + * linked above for details). + */ +function makeRqOptimisticUpdateOptions( + queryClient: QueryClient, + optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition< + ActionInput, + CachedData + >[] +): Pick { + async function onMutate(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + (generalDefinition) => + getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ); + + // Cancel any outgoing refetches (so they don't overwrite our optimistic update). + // Theoretically, we can be a bit faster. Instead of awaiting the + // cancellation of all queries, we could cancel and update them in parallel. + // However, awaiting cancellation hasn't yet proven to be a performance bottleneck. + await Promise.all( + specificOptimisticUpdateDefinitions.map(({ queryKey }) => + queryClient.cancelQueries(queryKey) + ) + ); + + // We're using a Map to correctly serialize query keys that contain objects. + const previousData = new Map(); + specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => { + // Snapshot the currently cached value. + const previousDataForQuery: CachedData = + queryClient.getQueryData(queryKey); + + // Attempt to optimistically update the cache using the new value. + try { + queryClient.setQueryData(queryKey, updateQuery); + } catch (e) { + console.error( + "The `updateQuery` function threw an exception, skipping optimistic update:" + ); + console.error(e); + } + + // Remember the snapshotted value to restore in case of an error. + previousData.set(queryKey, previousDataForQuery); + }); + + return { previousData }; + } + + function onError(_err, _item, context) { + // All we do in case of an error is roll back all optimistic updates. We ensure + // not to do anything else because React Query rethrows the error. This allows + // the programmer to handle the error as they usually would (i.e., we want the + // error handling to work as it would if the programmer wasn't using optimistic + // updates). + context.previousData.forEach(async (data, queryKey) => { + await queryClient.cancelQueries(queryKey); + queryClient.setQueryData(queryKey, data); + }); + } + + return { + onMutate, + onError, + }; +} + +/** + * Constructs the definition for optimistically updating a specific item. It + * uses a closure over the updated item to construct an item-specific query key + * (e.g., useful when the query key depends on an ID). + * + * @param optimisticUpdateDefinition The general, "uninstantiated" optimistic + * update definition with a function for constructing the query key. + * @param item The item triggering the Action/optimistic update (i.e., the + * argument passed to the Action). + * @returns A specific optimistic update definition which corresponds to the + * provided definition and closes over the provided item. + */ +function getOptimisticUpdateDefinitionForSpecificItem( + optimisticUpdateDefinition: InternalOptimisticUpdateDefinition< + ActionInput, + CachedData + >, + item: ActionInput +): SpecificOptimisticUpdateDefinition { + const { getQueryKey, updateQuery } = optimisticUpdateDefinition; + return { + queryKey: getQueryKey(item), + updateQuery: (old) => updateQuery(item, old), + }; +} + +/** + * Translates a Wasp query specifier to a query cache key used by React Query. + * + * @param querySpecifier A query specifier that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier( + querySpecifier: QuerySpecifier +): QueryKey { + const [queryFn, ...otherKeys] = querySpecifier; + return [...(queryFn as any).queryCacheKey, ...otherKeys]; +} diff --git a/waspc/data/Generator/templates/sdk/rpc/queries/core.d.ts b/waspc/data/Generator/templates/sdk/rpc/queries/core.d.ts new file mode 100644 index 0000000000..ddbb4f2b8e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/queries/core.d.ts @@ -0,0 +1,23 @@ +import { type Query } from '..' +import { Route } from 'wasp/types' +import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types' + +export function createQuery( + queryRoute: string, + entitiesUsed: any[] +): QueryFor + +export function addMetadataToQuery( + query: (...args: any[]) => Promise, + metadata: { + relativeQueryPath: string + queryRoute: Route + entitiesUsed: string[] + } +): void + +type QueryFor = Expand< + Query[0], _Awaited<_ReturnType>> +> + +type GenericBackendQuery = (args: never, context: any) => unknown diff --git a/waspc/data/Generator/templates/sdk/rpc/queries/core.js b/waspc/data/Generator/templates/sdk/rpc/queries/core.js new file mode 100644 index 0000000000..616fb82958 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/queries/core.js @@ -0,0 +1,30 @@ +import { callOperation, makeOperationRoute } from 'wasp/operations' +import { + addResourcesUsedByQuery, + getActiveOptimisticUpdates, +} from 'wasp/operations/resources' + +export function createQuery(relativeQueryPath, entitiesUsed) { + const queryRoute = makeOperationRoute(relativeQueryPath) + + async function query(queryKey, queryArgs) { + const serverResult = await callOperation(queryRoute, queryArgs) + return getActiveOptimisticUpdates(queryKey).reduce( + (result, update) => update(result), + serverResult, + ) + } + + addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) + + return query +} + +export function addMetadataToQuery( + query, + { relativeQueryPath, queryRoute, entitiesUsed } +) { + query.queryCacheKey = [relativeQueryPath] + query.route = queryRoute + addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed) +} diff --git a/waspc/data/Generator/templates/sdk/rpc/queries/index.ts b/waspc/data/Generator/templates/sdk/rpc/queries/index.ts new file mode 100644 index 0000000000..a03221553d --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/queries/index.ts @@ -0,0 +1,6 @@ +import { createQuery } from './core' +import { GetTasks } from 'wasp/server/queries' + +export const getTasks = createQuery('operations/get-tasks', ['Task']) + +export { addMetadataToQuery } from './core' diff --git a/waspc/data/Generator/templates/sdk/rpc/queryClient.ts b/waspc/data/Generator/templates/sdk/rpc/queryClient.ts new file mode 100644 index 0000000000..448be4c5ce --- /dev/null +++ b/waspc/data/Generator/templates/sdk/rpc/queryClient.ts @@ -0,0 +1,33 @@ +import { QueryClient } from "@tanstack/react-query"; + +type QueryClientConfig = object; + +const defaultQueryClientConfig = {}; + +let queryClientConfig: QueryClientConfig, + resolveQueryClientInitialized: (...args: any[]) => any, + isQueryClientInitialized: boolean; + +export const queryClientInitialized: Promise = new Promise( + (resolve) => { + resolveQueryClientInitialized = resolve; + } +); + +export function configureQueryClient(config: QueryClientConfig): void { + if (isQueryClientInitialized) { + throw new Error( + "Attempted to configure the QueryClient after initialization" + ); + } + + queryClientConfig = config; +} + +export function initializeQueryClient(): void { + const queryClient = new QueryClient( + queryClientConfig ?? defaultQueryClientConfig + ); + isQueryClientInitialized = true; + resolveQueryClientInitialized(queryClient); +} diff --git a/waspc/data/Generator/templates/server/src/_types/index.ts b/waspc/data/Generator/templates/sdk/server/_types/index.ts similarity index 87% rename from waspc/data/Generator/templates/server/src/_types/index.ts rename to waspc/data/Generator/templates/sdk/server/_types/index.ts index 2b3c6bf5ae..7b64cec772 100644 --- a/waspc/data/Generator/templates/server/src/_types/index.ts +++ b/waspc/data/Generator/templates/sdk/server/_types/index.ts @@ -1,19 +1,19 @@ {{={= =}=}} -import { type Expand } from "../universal/types.js"; +import { type Expand } from 'wasp/universal/types'; import { type Request, type Response } from 'express' import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core' -import prisma from "../dbClient.js" +import prisma from "wasp/server/dbClient" {=# isAuthEnabled =} import { type {= userEntityName =}, type {= authEntityName =}, type {= authIdentityEntityName =}, -} from "../entities" +} from "wasp/entities" import { type EmailProviderData, type UsernameProviderData, type OAuthProviderData, -} from '../auth/utils.js' +} from 'wasp/auth/utils' {=/ isAuthEnabled =} import { type _Entity } from "./taggedEntities" import { type Payload } from "./serialization"; @@ -88,20 +88,20 @@ type Context = Expand<{ {=# isAuthEnabled =} type ContextWithUser = Expand & { user?: SanitizedUser }> -// TODO: This type must match the logic in core/auth.js (if we remove the +// TODO: This type must match the logic in core/session.js (if we remove the // password field from the object there, we must do the same here). Ideally, // these two things would live in the same place: // https://github.com/wasp-lang/wasp/issues/965 -export type DeserializedAuthEntity = Expand & { +export type DeserializedAuthIdentity = Expand & { providerData: Omit | Omit | OAuthProviderData }> export type SanitizedUser = {= userEntityName =} & { {= authFieldOnUserEntityName =}: {= authEntityName =} & { - {= identitiesFieldOnAuthEntityName =}: DeserializedAuthEntity[] + {= identitiesFieldOnAuthEntityName =}: DeserializedAuthIdentity[] } | null } -export type { ProviderName } from '../auth/utils.js' +export type { ProviderName } from 'wasp/auth/utils' {=/ isAuthEnabled =} diff --git a/waspc/data/Generator/templates/server/src/_types/serialization.ts b/waspc/data/Generator/templates/sdk/server/_types/serialization.ts similarity index 100% rename from waspc/data/Generator/templates/server/src/_types/serialization.ts rename to waspc/data/Generator/templates/sdk/server/_types/serialization.ts diff --git a/waspc/data/Generator/templates/server/src/_types/taggedEntities.ts b/waspc/data/Generator/templates/sdk/server/_types/taggedEntities.ts similarity index 96% rename from waspc/data/Generator/templates/server/src/_types/taggedEntities.ts rename to waspc/data/Generator/templates/sdk/server/_types/taggedEntities.ts index 4e38484729..eda8037c0f 100644 --- a/waspc/data/Generator/templates/server/src/_types/taggedEntities.ts +++ b/waspc/data/Generator/templates/sdk/server/_types/taggedEntities.ts @@ -10,7 +10,7 @@ import { {=# entities =} type {= name =}, {=/ entities =} -} from '../entities' +} from 'wasp/entities' {=# entities =} export type {= internalTypeName =} = WithName<{= name =}, "{= name =}"> diff --git a/waspc/data/Generator/templates/sdk/server/actions/index.ts b/waspc/data/Generator/templates/sdk/server/actions/index.ts new file mode 100644 index 0000000000..c1832fd6d6 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/server/actions/index.ts @@ -0,0 +1,26 @@ +{{={= =}=}} +import prisma from 'wasp/server/dbClient' +{=! TODO: This template is exactly the same at the moment as one for queries, + consider in the future if it is worth removing this duplication. =} + +{=! TODO: This will generate multiple import statements even though they're + importing symbols from the same file. We should improve our importing machinery + to support multiple imports from the same file =} +{=# operations =} +{=& jsFn.importStatement =} +{=/ operations =} +{=# operations =} + +export type {= operationTypeName =} = typeof {= jsFn.importIdentifier =} + +export const {= operationName =} = async (args, context) => { + return ({= jsFn.importIdentifier =} as any)(args, { + ...context, + entities: { + {=# entities =} + {= name =}: prisma.{= prismaIdentifier =}, + {=/ entities =} + }, + }) +} +{=/ operations =} diff --git a/waspc/data/Generator/templates/sdk/server/actions/types.ts b/waspc/data/Generator/templates/sdk/server/actions/types.ts new file mode 100644 index 0000000000..15a045acdf --- /dev/null +++ b/waspc/data/Generator/templates/sdk/server/actions/types.ts @@ -0,0 +1,34 @@ +{{={= =}=}} +{=! TODO: This template is exactly the same at the moment as one for query + types, consider whether it makes sense to address this in the future. =} +import { + {=# allEntities =} + type {= internalTypeName =}, + {=/ allEntities =} + {=# shouldImportNonAuthenticatedOperation =} + type Action, + {=/ shouldImportNonAuthenticatedOperation =} + {=# shouldImportAuthenticatedOperation =} + type AuthenticatedAction, + {=/ shouldImportAuthenticatedOperation =} + type Payload, +} from 'wasp/server/_types' + +{=# operations =} +export type {= typeName =} = + {=# usesAuth =} + AuthenticatedAction< + {=/ usesAuth =} + {=^ usesAuth =} + Action< + {=/ usesAuth =} + [ + {=# entities =} + {= internalTypeName =}, + {=/ entities =} + ], + Input, + Output + > + +{=/ operations =} \ No newline at end of file diff --git a/waspc/data/Generator/templates/server/src/config.js b/waspc/data/Generator/templates/sdk/server/config.js similarity index 96% rename from waspc/data/Generator/templates/server/src/config.js rename to waspc/data/Generator/templates/sdk/server/config.js index e38a10e965..aee9bbf323 100644 --- a/waspc/data/Generator/templates/server/src/config.js +++ b/waspc/data/Generator/templates/sdk/server/config.js @@ -1,7 +1,7 @@ {{={= =}=}} import merge from 'lodash.merge' -import { stripTrailingSlash } from "./universal/url.js"; +import { stripTrailingSlash } from "wasp/universal/url"; const env = process.env.NODE_ENV || 'development' diff --git a/waspc/data/Generator/templates/server/src/dbClient.ts b/waspc/data/Generator/templates/sdk/server/dbClient.ts similarity index 94% rename from waspc/data/Generator/templates/server/src/dbClient.ts rename to waspc/data/Generator/templates/sdk/server/dbClient.ts index e6319ff696..66e7801be3 100644 --- a/waspc/data/Generator/templates/server/src/dbClient.ts +++ b/waspc/data/Generator/templates/sdk/server/dbClient.ts @@ -1,4 +1,3 @@ -{{={= =}=}} import Prisma from '@prisma/client' diff --git a/waspc/data/Generator/templates/sdk/server/queries/index.ts b/waspc/data/Generator/templates/sdk/server/queries/index.ts new file mode 100644 index 0000000000..92cb7b7f4f --- /dev/null +++ b/waspc/data/Generator/templates/sdk/server/queries/index.ts @@ -0,0 +1,26 @@ +{{={= =}=}} +import prisma from 'wasp/server/dbClient' +{=! TODO: This template is exactly the same at the moment as one for actions, + consider in the future if it is worth removing this duplication. =} + +{=! TODO: This will generate multiple import statements even though they're + importing symbols from the same file. We should improve our importing machinery + to support multiple imports from the same file =} +{=# operations =} +{=& jsFn.importStatement =} +{=/ operations =} +{=# operations =} + +export type {= operationTypeName =} = typeof {= jsFn.importIdentifier =} + +export const {= operationName =} = async (args, context) => { + return ({= jsFn.importIdentifier =} as any)(args, { + ...context, + entities: { + {=# entities =} + {= name =}: prisma.{= prismaIdentifier =}, + {=/ entities =} + }, + }) +} +{=/ operations =} diff --git a/waspc/data/Generator/templates/sdk/server/queries/types.ts b/waspc/data/Generator/templates/sdk/server/queries/types.ts new file mode 100644 index 0000000000..3ae08913a3 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/server/queries/types.ts @@ -0,0 +1,35 @@ +{{={= =}=}} +{=! TODO: This template is exactly the same at the moment as one for action + types, consider whether it makes sense to address this in the future. =} + +import { + {=# allEntities =} + type {= internalTypeName =}, + {=/ allEntities =} + {=# shouldImportNonAuthenticatedOperation =} + type Query, + {=/ shouldImportNonAuthenticatedOperation =} + {=# shouldImportAuthenticatedOperation =} + type AuthenticatedQuery, + {=/ shouldImportAuthenticatedOperation =} + type Payload, +} from 'wasp/server/_types' + +{=# operations =} +export type {= typeName =} = + {=# usesAuth =} + AuthenticatedQuery< + {=/ usesAuth =} + {=^ usesAuth =} + Query< + {=/ usesAuth =} + [ + {=# entities =} + {= internalTypeName =}, + {=/ entities =} + ], + Input, + Output + > + +{=/ operations =} \ No newline at end of file diff --git a/waspc/data/Generator/templates/sdk/server/utils.ts b/waspc/data/Generator/templates/sdk/server/utils.ts new file mode 100644 index 0000000000..44f05bfc7c --- /dev/null +++ b/waspc/data/Generator/templates/sdk/server/utils.ts @@ -0,0 +1,71 @@ +{{={= =}=}} +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + +{=# isAuthEnabled =} +import { type SanitizedUser } from 'wasp/server/_types/index.js' +{=/ isAuthEnabled =} + +type RequestWithExtraFields = Request & { + {=# isAuthEnabled =} + user?: SanitizedUser + {=/ isAuthEnabled =} +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/data/Generator/templates/sdk/tsconfig.json b/waspc/data/Generator/templates/sdk/tsconfig.json new file mode 100644 index 0000000000..1d861dcecc --- /dev/null +++ b/waspc/data/Generator/templates/sdk/tsconfig.json @@ -0,0 +1,38 @@ +{{={= =}=}} +{ + "extends": "@tsconfig/node{= majorNodeVersion =}/tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "lib": [ + "esnext", + "dom", + "DOM.Iterable" + ], + "strict": false, + // Overriding this because we want to use top-level await + "module": "esnext", + "target": "es2017", + // Enable source map for debugging and go-to-definition + "sourceMap": true, + // The remaining settings should match the extended nodeXY/tsconfig.json, but I kept + // them here to be explicit. + // Enable default imports in TypeScript. + "esModuleInterop": true, + "moduleResolution": "node", + "outDir": "dist", + "allowJs": true + // todo(filip): Only works with common js, see https://www.typescriptlang.org/tsconfig#paths and daily-article. + // "paths": { + // "@wasp/*": [ + // "./*.js" + // ] + // } + }, + "include": [ + "." + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/waspc/data/Generator/templates/sdk/types/index.ts b/waspc/data/Generator/templates/sdk/types/index.ts new file mode 100644 index 0000000000..982b766e37 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/types/index.ts @@ -0,0 +1,9 @@ +// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs). +export enum HttpMethod { + Get = 'GET', + Post = 'POST', + Put = 'PUT', + Delete = 'DELETE', +} + +export type Route = { method: HttpMethod; path: string } diff --git a/waspc/data/Generator/templates/universal/types.ts b/waspc/data/Generator/templates/sdk/universal/types.ts similarity index 100% rename from waspc/data/Generator/templates/universal/types.ts rename to waspc/data/Generator/templates/sdk/universal/types.ts diff --git a/waspc/data/Generator/templates/universal/url.ts b/waspc/data/Generator/templates/sdk/universal/url.ts similarity index 100% rename from waspc/data/Generator/templates/universal/url.ts rename to waspc/data/Generator/templates/sdk/universal/url.ts diff --git a/waspc/data/Generator/templates/universal/validators.js b/waspc/data/Generator/templates/sdk/universal/validators.js similarity index 100% rename from waspc/data/Generator/templates/universal/validators.js rename to waspc/data/Generator/templates/sdk/universal/validators.js diff --git a/waspc/data/Generator/templates/server/nodemon.json b/waspc/data/Generator/templates/server/nodemon.json index 9ac8c1df77..01fe71701a 100644 --- a/waspc/data/Generator/templates/server/nodemon.json +++ b/waspc/data/Generator/templates/server/nodemon.json @@ -4,7 +4,9 @@ }, "watch": [ "src/", + "../../../src/", ".env" ], + "comment-filip": "We now have to watch ../../../src/ because we're importing client files directly", "ext": "ts,mts,js,mjs,json" } diff --git a/waspc/data/Generator/templates/server/package.json b/waspc/data/Generator/templates/server/package.json index 0b3f962d56..1a80a9ee47 100644 --- a/waspc/data/Generator/templates/server/package.json +++ b/waspc/data/Generator/templates/server/package.json @@ -4,9 +4,10 @@ "version": "0.0.0", "private": true, "type": "module", + "comment-filip": "The server.js location changed because we have now included client source files above .wasp/out/server/src.", "scripts": { "build": "npx tsc", - "start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/server.js", + "start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/.wasp/out/server/src/server.js", "build-and-start": "npm run build && npm run start", "watch": "nodemon --exec 'npm run build-and-start || exit 1'", "validate-env": "node -r dotenv/config ./scripts/validate-env.mjs", diff --git a/waspc/data/Generator/templates/server/scripts/validate-env.mjs b/waspc/data/Generator/templates/server/scripts/validate-env.mjs index fb68580bbb..ac264b7961 100644 --- a/waspc/data/Generator/templates/server/scripts/validate-env.mjs +++ b/waspc/data/Generator/templates/server/scripts/validate-env.mjs @@ -1,4 +1,4 @@ -import { throwIfNotValidAbsoluteURL } from './universal/validators.mjs'; +import { throwIfNotValidAbsoluteURL } from 'wasp/universal/validators'; console.info("🔍 Validating environment variables..."); throwIfNotValidAbsoluteURL(process.env.WASP_WEB_CLIENT_URL, 'Environment variable WASP_WEB_CLIENT_URL'); diff --git a/waspc/data/Generator/templates/server/src/actions/_action.ts b/waspc/data/Generator/templates/server/src/actions/_action.ts index 1bc6c0ad40..11b092cb1a 100644 --- a/waspc/data/Generator/templates/server/src/actions/_action.ts +++ b/waspc/data/Generator/templates/server/src/actions/_action.ts @@ -1,5 +1,5 @@ {{={= =}=}} -import prisma from '../dbClient.js' +import prisma from 'wasp/server/dbClient' {=& jsFn.importStatement =} diff --git a/waspc/data/Generator/templates/server/src/actions/types.ts b/waspc/data/Generator/templates/server/src/actions/types.ts index c56080ce20..e0fa2e9237 100644 --- a/waspc/data/Generator/templates/server/src/actions/types.ts +++ b/waspc/data/Generator/templates/server/src/actions/types.ts @@ -12,7 +12,7 @@ import { type AuthenticatedAction, {=/ shouldImportAuthenticatedOperation =} type Payload, -} from '../_types' +} from 'wasp/server/_types' {=# operations =} export type {= typeName =} = diff --git a/waspc/data/Generator/templates/server/src/app.js b/waspc/data/Generator/templates/server/src/app.js index a15cb96cea..9db0e1b75d 100644 --- a/waspc/data/Generator/templates/server/src/app.js +++ b/waspc/data/Generator/templates/server/src/app.js @@ -1,6 +1,6 @@ import express from 'express' -import HttpError from './core/HttpError.js' +import HttpError from 'wasp/core/HttpError' import indexRouter from './routes/index.js' // TODO: Consider extracting most of this logic into createApp(routes, path) function so that diff --git a/waspc/data/Generator/templates/server/src/auth/index.ts b/waspc/data/Generator/templates/server/src/auth/index.ts index cf74b0773e..354fbe542a 100644 --- a/waspc/data/Generator/templates/server/src/auth/index.ts +++ b/waspc/data/Generator/templates/server/src/auth/index.ts @@ -1 +1 @@ -export { defineAdditionalSignupFields } from './providers/types.js'; +export { defineUserSignupFields } from './providers/types.js'; diff --git a/waspc/data/Generator/templates/server/src/auth/jwt.ts b/waspc/data/Generator/templates/server/src/auth/jwt.ts new file mode 100644 index 0000000000..b244990158 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/jwt.ts @@ -0,0 +1,12 @@ +import jwt from 'jsonwebtoken' +import util from 'util' + +import config from 'wasp/server/config' + +const jwtSign = util.promisify(jwt.sign) +const jwtVerify = util.promisify(jwt.verify) + +const JWT_SECRET = config.auth.jwtSecret + +export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) +export const verify = (token) => jwtVerify(token, JWT_SECRET) diff --git a/waspc/data/Generator/templates/server/src/auth/lucia.ts b/waspc/data/Generator/templates/server/src/auth/lucia.ts new file mode 100644 index 0000000000..9d53af0a1c --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/lucia.ts @@ -0,0 +1,55 @@ +{{={= =}=}} +import { Lucia } from "lucia"; +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; +import prisma from 'wasp/server/dbClient' +import { type {= userEntityUpper =} } from "wasp/entities" + +const prismaAdapter = new PrismaAdapter( + // Using `as any` here since Lucia's model types are not compatible with Prisma 4 + // model types. This is a temporary workaround until we migrate to Prisma 5. + // This **works** in runtime, but Typescript complains about it. + prisma.{= sessionEntityLower =} as any, + prisma.{= authEntityLower =} as any +); + +/** + * We are using Lucia for session management. + * + * Some details: + * 1. We are using the Prisma adapter for Lucia. + * 2. We are not using cookies for session management. Instead, we are using + * the Authorization header to send the session token. + * 3. Our `Session` entity is connected to the `Auth` entity. + * 4. We are exposing the `userId` field from the `Auth` entity to + * make fetching the User easier. + */ +export const auth = new Lucia<{}, { + userId: {= userEntityUpper =}['id'] +}>(prismaAdapter, { + // Since we are not using cookies, we don't need to set any cookie options. + // But in the future, if we decide to use cookies, we can set them here. + + // sessionCookie: { + // name: "session", + // expires: true, + // attributes: { + // secure: !config.isDevelopment, + // sameSite: "lax", + // }, + // }, + getUserAttributes({ userId }) { + return { + userId, + }; + }, +}); + +declare module "lucia" { + interface Register { + Lucia: typeof auth; + DatabaseSessionAttributes: {}; + DatabaseUserAttributes: { + userId: {= userEntityUpper =}['id'] + }; + } +} diff --git a/waspc/data/Generator/templates/server/src/auth/password.ts b/waspc/data/Generator/templates/server/src/auth/password.ts new file mode 100644 index 0000000000..a359892b5e --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/password.ts @@ -0,0 +1,15 @@ +import SecurePassword from 'secure-password' + +const SP = new SecurePassword() + +export const hashPassword = async (password: string): Promise => { + const hashedPwdBuffer = await SP.hash(Buffer.from(password)) + return hashedPwdBuffer.toString("base64") +} + +export const verifyPassword = async (hashedPassword: string, password: string): Promise => { + const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) + if (result !== SecurePassword.VALID) { + throw new Error('Invalid password.') + } +} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts index a397bfa4be..5d6a8c6c2d 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts @@ -6,13 +6,13 @@ import { makeOAuthInit } from "../oauth/init.js"; import type { ProviderConfig } from "../types.js"; import type { OAuthConfig } from "../oauth/types.js"; -{=# userFieldsFn.isDefined =} -{=& userFieldsFn.importStatement =} -const _waspGetUserFieldsFn = {= userFieldsFn.importIdentifier =} -{=/ userFieldsFn.isDefined =} -{=^ userFieldsFn.isDefined =} -const _waspGetUserFieldsFn = undefined -{=/ userFieldsFn.isDefined =} +{=# userSignupFields.isDefined =} +{=& userSignupFields.importStatement =} +const _waspUserSignupFields = {= userSignupFields.importIdentifier =} +{=/ userSignupFields.isDefined =} +{=^ userSignupFields.isDefined =} +const _waspUserSignupFields = undefined +{=/ userSignupFields.isDefined =} {=# configFn.isDefined =} {=& configFn.importStatement =} const _waspUserDefinedConfigFn = {= configFn.importIdentifier =} @@ -32,7 +32,7 @@ const _waspConfig: ProviderConfig = { displayName: "{= displayName =}", init: makeOAuthInit({ npmPackage: '{= npmPackage =}', - getUserFieldsFn: _waspGetUserFieldsFn, + userSignupFields: _waspUserSignupFields, userDefinedConfigFn: _waspUserDefinedConfigFn, oAuthConfig: _waspOAuthConfig, }), diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts index ef327d20b5..979514bb24 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts @@ -12,6 +12,14 @@ import { verifyEmail } from "../email/verifyEmail.js"; import { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from "../email/types.js"; import { handleRejection } from "../../../utils.js"; +{=# userSignupFields.isDefined =} +{=& userSignupFields.importStatement =} +const _waspUserSignupFields = {= userSignupFields.importIdentifier =} +{=/ userSignupFields.isDefined =} +{=^ userSignupFields.isDefined =} +const _waspUserSignupFields = undefined +{=/ userSignupFields.isDefined =} + {=# getVerificationEmailContent.isDefined =} {=& getVerificationEmailContent.importStatement =} const _waspGetVerificationEmailContent: GetVerificationEmailContentFn = {= getVerificationEmailContent.importIdentifier =}; @@ -53,16 +61,20 @@ const config: ProviderConfig = { createRouter() { const router = Router(); - const loginRoute = handleRejection(getLoginRoute({ - allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =}, - })); + const loginRoute = handleRejection(getLoginRoute()); router.post('/login', loginRoute); const signupRoute = handleRejection(getSignupRoute({ + userSignupFields: _waspUserSignupFields, fromField, clientRoute: '{= emailVerificationClientRoute =}', getVerificationEmailContent: _waspGetVerificationEmailContent, - allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =}, + {=# isDevelopment =} + isEmailAutoVerified: process.env.SKIP_EMAIL_VERIFICATION_IN_DEV === 'true', + {=/ isDevelopment =} + {=^ isDevelopment =} + isEmailAutoVerified: false, + {=/ isDevelopment =} })); router.post('/signup', signupRoute); diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/username.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/username.ts index f7b78ed83b..c9846f5187 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/username.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/username.ts @@ -3,9 +3,17 @@ import { Router } from "express"; import login from "../username/login.js"; -import signup from "../username/signup.js"; +import { getSignupRoute } from "../username/signup.js"; import { ProviderConfig } from "../types.js"; +{=# userSignupFields.isDefined =} +{=& userSignupFields.importStatement =} +const _waspUserSignupFields = {= userSignupFields.importIdentifier =} +{=/ userSignupFields.isDefined =} +{=^ userSignupFields.isDefined =} +const _waspUserSignupFields = undefined +{=/ userSignupFields.isDefined =} + const config: ProviderConfig = { id: "{= providerId =}", displayName: "{= displayName =}", @@ -13,7 +21,10 @@ const config: ProviderConfig = { const router = Router(); router.post('/login', login); - router.post('/signup', signup); + const signupRoute = getSignupRoute({ + userSignupFields: _waspUserSignupFields, + }); + router.post('/signup', signupRoute); return router; }, diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts index b50422e3a6..ee987ca193 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts @@ -1,23 +1,20 @@ import { Request, Response } from 'express'; -import { verifyPassword, throwInvalidCredentialsError } from "../../../core/auth.js"; +import { throwInvalidCredentialsError } from '../../utils.js' +import { verifyPassword } from '../../password.js' import { createProviderId, findAuthIdentity, findAuthWithUserBy, - createAuthToken, deserializeAndSanitizeProviderData, -} from "../../utils.js"; -import { ensureValidEmail, ensurePasswordIsPresent } from "../../validation.js"; +} from '../../utils.js' +import { createSession } from '../../session.js' +import { ensureValidEmail, ensurePasswordIsPresent } from '../../validation.js' -export function getLoginRoute({ - allowUnverifiedLogin, -}: { - allowUnverifiedLogin: boolean -}) { +export function getLoginRoute() { return async function login( req: Request<{ email: string; password: string; }>, res: Response, - ): Promise> { + ): Promise> { const fields = req.body ?? {} ensureValidArgs(fields) @@ -28,7 +25,7 @@ export function getLoginRoute({ throwInvalidCredentialsError() } const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData) - if (!providerData.isEmailVerified && !allowUnverifiedLogin) { + if (!providerData.isEmailVerified) { throwInvalidCredentialsError() } try { @@ -38,9 +35,11 @@ export function getLoginRoute({ } const auth = await findAuthWithUserBy({ id: authIdentity.authId }) - const token = await createAuthToken(auth.userId) + const session = await createSession(auth.id) - return res.json({ token }) + return res.json({ + sessionId: session.id, + }) }; } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts index c0936bac59..cc70a3677a 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts @@ -13,7 +13,7 @@ import { import { ensureValidEmail } from "../../validation.js"; import type { EmailFromField } from '../../../email/core/types.js'; import { GetPasswordResetEmailContentFn } from './types.js'; -import HttpError from '../../../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError'; export function getRequestPasswordResetRoute({ fromField, diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts index 3f01d47c32..6d8f0d6753 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts @@ -8,7 +8,7 @@ import { } from "../../utils.js"; import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js"; import { tokenVerificationErrors } from "./types.js"; -import HttpError from '../../../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError'; export async function resetPassword( req: Request<{ token: string; password: string; }>, diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index e6755e2b47..984f38362d 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -17,19 +17,22 @@ import { } from "./utils.js"; import { ensureValidEmail, ensureValidPassword, ensurePasswordIsPresent } from "../../validation.js"; import { GetVerificationEmailContentFn } from './types.js'; -import { validateAndGetAdditionalFields } from '../../utils.js' -import HttpError from '../../../core/HttpError.js'; +import { validateAndGetUserFields } from '../../utils.js' +import HttpError from 'wasp/core/HttpError'; +import { type UserSignupFields } from '../types.js'; export function getSignupRoute({ + userSignupFields, fromField, clientRoute, getVerificationEmailContent, - allowUnverifiedLogin, + isEmailAutoVerified, }: { + userSignupFields?: UserSignupFields; fromField: EmailFromField; clientRoute: string; getVerificationEmailContent: GetVerificationEmailContentFn; - allowUnverifiedLogin: boolean; + isEmailAutoVerified: boolean; }) { return async function signup( req: Request<{ email: string; password: string; }>, @@ -65,20 +68,7 @@ export function getSignupRoute({ * else's email address and therefore permanently making that email * address unavailable for later account creation (by real owner). */ - if (existingAuthIdentity) { - if (allowUnverifiedLogin) { - /** - * This is the case where we allow unverified login. - * - * If we pretended that the user was created successfully that would bring - * us little value: the attacker would not be able to login and figure out - * if the user exists or not, anyway. - * - * So, we throw an error that says that the user already exists. - */ - throw new HttpError(422, "User with that email already exists.") - } - + if (existingAuthIdentity) { const providerData = deserializeAndSanitizeProviderData<'email'>(existingAuthIdentity.providerData); // TOOD: faking work makes sense if the time spent on faking the work matches the time @@ -103,11 +93,14 @@ export function getSignupRoute({ } } - const userFields = await validateAndGetAdditionalFields(fields); + const userFields = await validateAndGetUserFields( + fields, + userSignupFields, + ); const newUserProviderData = await sanitizeAndSerializeProviderData<'email'>({ hashedPassword: fields.password, - isEmailVerified: false, + isEmailVerified: isEmailAutoVerified ? true : false, emailVerificationSentAt: null, passwordResetSentAt: null, }); @@ -116,12 +109,20 @@ export function getSignupRoute({ await createUser( providerId, newUserProviderData, - userFields, + // Using any here because we want to avoid TypeScript errors and + // rely on Prisma to validate the data. + userFields as any ); } catch (e: unknown) { rethrowPossibleAuthError(e); } + // Wasp allows for auto-verification of emails in development mode to + // make writing e2e tests easier. + if (isEmailAutoVerified) { + return res.json({ success: true }); + } + const verificationLink = await createEmailVerificationLink(fields.email, clientRoute); try { await sendEmailVerificationEmail( @@ -135,7 +136,7 @@ export function getSignupRoute({ } catch (e: unknown) { console.error("Failed to send email verification email:", e); throw new HttpError(500, "Failed to send email verification email."); - } + } return res.json({ success: true }); }; diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts index a158b91482..7aeb36b64a 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts @@ -1,5 +1,5 @@ {{={= =}=}} -import { signData } from '../../../core/auth.js' +import { signData } from '../../jwt.js' import { emailSender } from '../../../email/index.js'; import { Email } from '../../../email/core/types.js'; import { @@ -8,7 +8,7 @@ import { findAuthIdentity, deserializeAndSanitizeProviderData, type EmailProviderData, -} from '../../utils.js'; +} from 'wasp/server/utils'; import waspServerConfig from '../../../config.js'; import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../../../entities/index.js' diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts index 7dc52d2576..de73a05ddf 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts @@ -7,7 +7,7 @@ import { deserializeAndSanitizeProviderData, } from '../../utils.js'; import { tokenVerificationErrors } from './types.js'; -import HttpError from '../../../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError'; export async function verifyEmail( diff --git a/waspc/data/Generator/templates/server/src/auth/providers/index.ts b/waspc/data/Generator/templates/server/src/auth/providers/index.ts index f3e3326fbe..44208c0375 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/index.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/index.ts @@ -3,7 +3,7 @@ import { join } from 'path' import { Router } from "express"; -import { getDirPathFromFileUrl, importJsFilesFromDir } from "../../utils.js"; +import { getDirPathFromFileUrl, importJsFilesFromDir } from "wasp/server/utils"; import { ProviderConfig } from "./types"; diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts index d8a4fe0b62..ffc7441737 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts @@ -3,7 +3,7 @@ import { Router } from "express" import passport from "passport" -import prisma from '../../../dbClient.js' +import prisma from 'wasp/server/dbClient' import waspServerConfig from '../../../config.js' import { type ProviderName, @@ -12,20 +12,22 @@ import { authConfig, contextWithUserEntity, createUser, - findAuthWithUserBy, - createAuthToken, rethrowPossibleAuthError, sanitizeAndSerializeProviderData, + validateAndGetUserFields, } from "../../utils.js" -import { type {= userEntityUpper =} } from "../../../entities/index.js" -import type { ProviderConfig, RequestWithWasp } from "../types.js" -import type { GetUserFieldsFn } from "./types.js" +import { createSession } from "../../session.js" +import { type {= authEntityUpper =} } from "../../../entities/index.js" +import type { ProviderConfig, RequestWithWasp, UserSignupFields } from "../types.js" import { handleRejection } from "../../../utils.js" // For oauth providers, we have an endpoint /login to get the auth URL, // and the /callback endpoint which is used to get the actual access_token and the user info. -export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn?: GetUserFieldsFn }) { - const { passportStrategyName, getUserFieldsFn } = initData; +export function createRouter(provider: ProviderConfig, initData: { + passportStrategyName: string, + userSignupFields?: UserSignupFields, +}) { + const { passportStrategyName, userSignupFields } = initData; const router = Router(); @@ -53,9 +55,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat const providerId = createProviderId(provider.id, providerProfile.id); try { - const userId = await getUserIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn) - const token = await createAuthToken(userId) - res.json({ token }) + const authId = await getAuthIdFromProviderDetails(providerId, providerProfile, userSignupFields) + const session = await createSession(authId) + return res.json({ + sessionId: session.id, + }) } catch (e) { rethrowPossibleAuthError(e) } @@ -67,11 +71,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat // We need a user id to create the auth token, so we either find an existing user // or create a new one if none exists for this provider. -async function getUserIdFromProviderDetails( +async function getAuthIdFromProviderDetails( providerId: ProviderId, providerProfile: any, - getUserFieldsFn?: GetUserFieldsFn, -): Promise<{= userEntityUpper =}['id']> { + userSignupFields?: UserSignupFields, +): Promise<{= authEntityUpper =}['id']> { const existingAuthIdentity = await prisma.{= authIdentityEntityLower =}.findUnique({ where: { providerName_providerUserId: providerId, @@ -86,11 +90,12 @@ async function getUserIdFromProviderDetails( }) if (existingAuthIdentity) { - return existingAuthIdentity.{= authFieldOnAuthIdentityEntityName =}.{= userFieldOnAuthEntityName =}.id + return existingAuthIdentity.{= authFieldOnAuthIdentityEntityName =}.id } else { - const userFields = getUserFieldsFn - ? await getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) - : {}; + const userFields = await validateAndGetUserFields( + { profile: providerProfile }, + userSignupFields, + ); // For now, we don't have any extra data for the oauth providers, so we just pass an empty object. const providerData = await sanitizeAndSerializeProviderData({}) @@ -98,9 +103,11 @@ async function getUserIdFromProviderDetails( const user = await createUser( providerId, providerData, - userFields, + // Using any here because we want to avoid TypeScript errors and + // rely on Prisma to validate the data. + userFields as any, ) - return user.id + return user.auth.id } } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts index ac5a56dafe..1462f3a2f7 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts @@ -2,10 +2,10 @@ import passport from "passport"; import waspServerConfig from '../../../config.js'; -import type { InitData, ProviderConfig, RequestWithWasp } from "../types.js"; -import type { OAuthConfig, GetUserFieldsFn, UserDefinedConfigFn } from "./types.js"; +import type { InitData, ProviderConfig, RequestWithWasp, UserSignupFields } from "../types.js"; +import type { OAuthConfig, UserDefinedConfigFn } from "./types.js"; -export function makeOAuthInit({ userDefinedConfigFn, getUserFieldsFn, npmPackage, oAuthConfig }: OAuthImports) { +export function makeOAuthInit({ userDefinedConfigFn, userSignupFields, npmPackage, oAuthConfig }: OAuthImports) { return async function init(provider: ProviderConfig): Promise { const userDefinedConfig = userDefinedConfigFn ? userDefinedConfigFn() @@ -35,7 +35,7 @@ export function makeOAuthInit({ userDefinedConfigFn, getUserFieldsFn, npmPackage return { passportStrategyName, - getUserFieldsFn, + userSignupFields, }; } } @@ -72,5 +72,5 @@ export type OAuthImports = { npmPackage: string; userDefinedConfigFn?: UserDefinedConfigFn; oAuthConfig: OAuthConfig; - getUserFieldsFn?: GetUserFieldsFn; + userSignupFields?: UserSignupFields; }; diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts index 390cd45923..d18295c676 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts @@ -12,8 +12,3 @@ export type OAuthConfig = { export type UserFieldsFromOAuthSignup = Prisma.{= userEntityName =}CreateInput export type UserDefinedConfigFn = () => { [key: string]: any } - -export type GetUserFieldsFn = ( - context: typeof contextWithUserEntity, - args: { profile: { [key: string]: any } }, -) => Promise diff --git a/waspc/data/Generator/templates/server/src/auth/providers/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/types.ts index e2ff6e09a7..f040c1c490 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/types.ts @@ -1,7 +1,7 @@ {{={= =}=}} import type { Router, Request } from 'express' import type { Prisma } from '@prisma/client' -import type { Expand } from '../../universal/types' +import type { Expand } from 'wasp/universal/types' import type { ProviderName } from '../utils' type UserEntityCreateInput = Prisma.{= userEntityUpper =}CreateInput @@ -24,16 +24,18 @@ export type InitData = { export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } -export type PossibleAdditionalSignupFields = Expand> +export type PossibleUserFields = Expand> -export function defineAdditionalSignupFields(config: { - [key in keyof PossibleAdditionalSignupFields]: FieldGetter< - PossibleAdditionalSignupFields[key] +export type UserSignupFields = { + [key in keyof PossibleUserFields]: FieldGetter< + PossibleUserFields[key] > -}) { - return config } type FieldGetter = ( data: { [key: string]: unknown } ) => Promise | T | undefined + +export function defineUserSignupFields(fields: UserSignupFields) { + return fields +} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/username/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/username/login.ts index 9bb5841bf5..79dc83a5eb 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/username/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/username/login.ts @@ -1,14 +1,15 @@ {{={= =}=}} -import { verifyPassword, throwInvalidCredentialsError } from '../../../core/auth.js' -import { handleRejection } from '../../../utils.js' +import { throwInvalidCredentialsError } from '../../utils.js' +import { handleRejection } from 'wasp/server/utils' +import { verifyPassword } from '../../password.js' import { createProviderId, findAuthIdentity, findAuthWithUserBy, - createAuthToken, deserializeAndSanitizeProviderData, } from '../../utils.js' +import { createSession } from '../../session.js' import { ensureValidUsername, ensurePasswordIsPresent } from '../../validation.js' export default handleRejection(async (req, res) => { @@ -32,9 +33,12 @@ export default handleRejection(async (req, res) => { const auth = await findAuthWithUserBy({ id: authIdentity.authId }) - const token = await createAuthToken(auth.userId) - return res.json({ token }) + const session = await createSession(auth.id) + + return res.json({ + sessionId: session.id, + }) }) function ensureValidArgs(args: unknown): void { diff --git a/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts index 478749b9b2..53b1561fd5 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts @@ -1,5 +1,5 @@ {{={= =}=}} -import { handleRejection } from '../../../utils.js' +import { handleRejection } from 'wasp/server/utils' import { createProviderId, createUser, @@ -11,33 +11,43 @@ import { ensurePasswordIsPresent, ensureValidPassword, } from '../../validation.js' -import { validateAndGetAdditionalFields } from '../../utils.js' +import { validateAndGetUserFields } from '../../utils.js' +import { type UserSignupFields } from '../types.js' -export default handleRejection(async (req, res) => { - const fields = req.body ?? {} - ensureValidArgs(fields) - - const userFields = await validateAndGetAdditionalFields(fields) - - const providerId = createProviderId('username', fields.username) - const providerData = await sanitizeAndSerializeProviderData<'username'>({ - hashedPassword: fields.password, +export function getSignupRoute({ + userSignupFields, +}: { + userSignupFields?: UserSignupFields; +}) { + return handleRejection(async function signup(req, res) { + const fields = req.body ?? {} + ensureValidArgs(fields) + + const userFields = await validateAndGetUserFields( + fields, + userSignupFields, + ); + + const providerId = createProviderId('username', fields.username) + const providerData = await sanitizeAndSerializeProviderData<'username'>({ + hashedPassword: fields.password, + }) + + try { + await createUser( + providerId, + providerData, + // Using any here because we want to avoid TypeScript errors and + // rely on Prisma to validate the data. + userFields as any + ) + } catch (e: unknown) { + rethrowPossibleAuthError(e) + } + + return res.json({ success: true }) }) - - try { - await createUser( - providerId, - providerData, - // Using any here because we want to avoid TypeScript errors and - // rely on Prisma to validate the data. - userFields as any - ) - } catch (e: unknown) { - rethrowPossibleAuthError(e) - } - - return res.json({ success: true }) -}) +} function ensureValidArgs(args: unknown): void { ensureValidUsername(args) diff --git a/waspc/data/Generator/templates/server/src/auth/session.ts b/waspc/data/Generator/templates/server/src/auth/session.ts new file mode 100644 index 0000000000..b7ddebc3ea --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/session.ts @@ -0,0 +1,108 @@ +{{={= =}=}} +import { Request as ExpressRequest } from "express"; + +import { type {= userEntityUpper =} } from "wasp/entities" +import { type SanitizedUser } from 'wasp/server/_types' + +import { auth } from "./lucia.js"; +import type { Session } from "lucia"; +import { + throwInvalidCredentialsError, + deserializeAndSanitizeProviderData, +} from "./utils.js"; + +import prisma from 'wasp/server/dbClient'; + +// Creates a new session for the `authId` in the database +export async function createSession(authId: string): Promise { + return auth.createSession(authId, {}); +} + +export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const authorizationHeader = req.headers["authorization"]; + + if (typeof authorizationHeader !== "string") { + return { + user: null, + session: null, + }; + } + + const sessionId = auth.readBearerToken(authorizationHeader); + if (!sessionId) { + return { + user: null, + session: null, + }; + } + + return getSessionAndUserFromSessionId(sessionId); +} + +export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const { session, user: authEntity } = await auth.validateSession(sessionId); + + if (!session || !authEntity) { + return { + user: null, + session: null, + }; + } + + return { + session, + user: await getUser(authEntity.userId) + } +} + +async function getUser(userId: {= userEntityUpper =}['id']): Promise { + const user = await prisma.{= userEntityLower =} + .findUnique({ + where: { id: userId }, + include: { + {= authFieldOnUserEntityName =}: { + include: { + {= identitiesFieldOnAuthEntityName =}: true + } + } + } + }) + + if (!user) { + throwInvalidCredentialsError() + } + + // TODO: This logic must match the type in _types/index.ts (if we remove the + // password field from the object here, we must to do the same there). + // Ideally, these two things would live in the same place: + // https://github.com/wasp-lang/wasp/issues/965 + const deserializedIdentities = user.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =}.map((identity) => { + const deserializedProviderData = deserializeAndSanitizeProviderData( + identity.providerData, + { + shouldRemovePasswordField: true, + } + ) + return { + ...identity, + providerData: deserializedProviderData, + } + }) + return { + ...user, + auth: { + ...user.auth, + identities: deserializedIdentities, + }, + } +} + +export function invalidateSession(sessionId: string): Promise { + return auth.invalidateSession(sessionId); +} diff --git a/waspc/data/Generator/templates/server/src/auth/user.ts b/waspc/data/Generator/templates/server/src/auth/user.ts index a5d987fc4e..137cf1b4b0 100644 --- a/waspc/data/Generator/templates/server/src/auth/user.ts +++ b/waspc/data/Generator/templates/server/src/auth/user.ts @@ -2,7 +2,7 @@ // We have them duplicated in this file and in data/Generator/templates/react-app/src/auth/user.ts // If you are changing the logic here, make sure to change it there as well. -import type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../_types/index' +import type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/index' export function getEmail(user: User): string | null { return findUserIdentity(user, "email")?.providerUserId ?? null; @@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null { return user.auth.identities[0].providerUserId ?? null; } -export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined { return user.auth.identities.find( (identity) => identity.providerName === providerName ); diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index 0ff4c7d29e..6a86efdfed 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -1,29 +1,20 @@ {{={= =}=}} -import { hashPassword, sign, verify } from '../core/auth.js' -import AuthError from '../core/AuthError.js' -import HttpError from '../core/HttpError.js' -import prisma from '../dbClient.js' -import { sleep } from '../utils.js' +import { hashPassword } from './password.js' +import { verify } from './jwt.js' +import AuthError from 'wasp/core/AuthError' +import HttpError from 'wasp/core/HttpError' +import prisma from 'wasp/server/dbClient' +import { sleep } from 'wasp/server/utils' import { type {= userEntityUpper =}, type {= authEntityUpper =}, type {= authIdentityEntityUpper =}, -} from '../entities/index.js' +} from 'wasp/entities/index.js' import { Prisma } from '@prisma/client'; import { throwValidationError } from './validation.js' -{=# additionalSignupFields.isDefined =} -{=& additionalSignupFields.importStatement =} -{=/ additionalSignupFields.isDefined =} - -import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js' -{=# additionalSignupFields.isDefined =} -const _waspAdditionalSignupFieldsConfig = {= additionalSignupFields.importIdentifier =} -{=/ additionalSignupFields.isDefined =} -{=^ additionalSignupFields.isDefined =} -const _waspAdditionalSignupFieldsConfig = {} as ReturnType -{=/ additionalSignupFields.isDefined =} +import { type UserSignupFields, type PossibleUserFields } from './providers/types.js' export type EmailProviderData = { hashedPassword: string; @@ -136,8 +127,10 @@ export async function findAuthWithUserBy( export async function createUser( providerId: ProviderId, serializedProviderData?: string, - userFields?: PossibleAdditionalSignupFields, -): Promise<{= userEntityUpper =}> { + userFields?: PossibleUserFields, +): Promise<{= userEntityUpper =} & { + auth: {= authEntityUpper =} +}> { return prisma.{= userEntityLower =}.create({ data: { // Using any here to prevent type errors when userFields are not @@ -154,7 +147,12 @@ export async function createUser( }, } }, - } + }, + // We need to include the Auth entity here because we need `authId` + // to be able to create a session. + include: { + {= authFieldOnUserEntityName =}: true, + }, }) } @@ -164,12 +162,6 @@ export async function deleteUserByAuthId(authId: string): Promise<{ count: numbe } } }) } -export async function createAuthToken( - userId: {= userEntityUpper =}['id'] -): Promise { - return sign(userId); -} - export async function verifyToken(token: string): Promise { return verify(token); } @@ -188,7 +180,7 @@ export async function doFakeWork(): Promise { export function rethrowPossibleAuthError(e: unknown): void { if (e instanceof AuthError) { - throwValidationError(e.message); + throwValidationError((e as any).message); } // Prisma code P2002 is for unique constraint violations. @@ -233,15 +225,23 @@ export function rethrowPossibleAuthError(e: unknown): void { throw e } -export async function validateAndGetAdditionalFields(data: { - [key: string]: unknown -}): Promise> { +export async function validateAndGetUserFields( + data: { + [key: string]: unknown + }, + userSignupFields?: UserSignupFields, +): Promise> { const { password: _password, ...sanitizedData } = data; const result: Record = {}; - for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) { + + if (!userSignupFields) { + return result; + } + + for (const [field, getFieldValue] of Object.entries(userSignupFields)) { try { const value = await getFieldValue(sanitizedData) result[field] = value @@ -297,3 +297,7 @@ function providerDataHasPasswordField( ): providerData is { hashedPassword: string } { return 'hashedPassword' in providerData; } + +export function throwInvalidCredentialsError(message?: string): void { + throw new HttpError(401, 'Invalid credentials', { message }) +} diff --git a/waspc/data/Generator/templates/server/src/auth/validation.ts b/waspc/data/Generator/templates/server/src/auth/validation.ts index f384a28c87..73bac13e21 100644 --- a/waspc/data/Generator/templates/server/src/auth/validation.ts +++ b/waspc/data/Generator/templates/server/src/auth/validation.ts @@ -1,4 +1,4 @@ -import HttpError from '../core/HttpError.js'; +import HttpError from 'wasp/core/HttpError'; export const PASSWORD_FIELD = 'password'; const USERNAME_FIELD = 'username'; diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js deleted file mode 100644 index 33105b1cbb..0000000000 --- a/waspc/data/Generator/templates/server/src/core/auth.js +++ /dev/null @@ -1,146 +0,0 @@ -{{={= =}=}} -import jwt from 'jsonwebtoken' -import SecurePassword from 'secure-password' -import util from 'util' -import { randomInt } from 'node:crypto' - -import prisma from '../dbClient.js' -import { handleRejection } from '../utils.js' -import HttpError from '../core/HttpError.js' -import config from '../config.js' -import { deserializeAndSanitizeProviderData } from '../auth/utils.js' - -const jwtSign = util.promisify(jwt.sign) -const jwtVerify = util.promisify(jwt.verify) - -const JWT_SECRET = config.auth.jwtSecret - -export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) -export const sign = (id, options) => signData({ id }, options) -export const verify = (token) => jwtVerify(token, JWT_SECRET) - -const auth = handleRejection(async (req, res, next) => { - const authHeader = req.get('Authorization') - if (!authHeader) { - // NOTE(matija): for now we let tokenless requests through and make it operation's - // responsibility to verify whether the request is authenticated or not. In the future - // we will develop our own system at Wasp-level for that. - return next() - } - - if (authHeader.startsWith('Bearer ')) { - const token = authHeader.substring(7, authHeader.length) - req.user = await getUserFromToken(token) - } else { - throwInvalidCredentialsError() - } - - next() -}) - -export async function getUserFromToken(token) { - let userIdFromToken - try { - userIdFromToken = (await verify(token)).id - } catch (error) { - if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) { - throwInvalidCredentialsError() - } else { - throw error - } - } - - const user = await prisma.{= userEntityLower =} - .findUnique({ - where: { id: userIdFromToken }, - include: { - {= authFieldOnUserEntityName =}: { - include: { - {= identitiesFieldOnAuthEntityName =}: true - } - } - } - }) - if (!user) { - throwInvalidCredentialsError() - } - - // TODO: This logic must match the type in types/index.ts (if we remove the - // password field from the object here, we must to do the same there). - // Ideally, these two things would live in the same place: - // https://github.com/wasp-lang/wasp/issues/965 - let sanitizedUser = { ...user } - sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =} = sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =}.map(identity => { - identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true }) - return identity - }); - return sanitizedUser -} - -const SP = new SecurePassword() - -export const hashPassword = async (password) => { - const hashedPwdBuffer = await SP.hash(Buffer.from(password)) - return hashedPwdBuffer.toString("base64") -} - -export const verifyPassword = async (hashedPassword, password) => { - const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) - if (result !== SecurePassword.VALID) { - throw new Error('Invalid password.') - } -} - -// Generates an unused username that looks similar to "quick-purple-sheep-91231". -// It generates several options and ensures it picks one that is not currently in use. -export function generateAvailableDictionaryUsername() { - const adjectives = ['fuzzy', 'tall', 'short', 'nice', 'happy', 'quick', 'slow', 'good', 'new', 'old', 'first', 'last', 'old', 'young'] - const colors = ['red', 'green', 'blue', 'white', 'black', 'brown', 'purple', 'orange', 'yellow'] - const nouns = ['wasp', 'cat', 'dog', 'lion', 'rabbit', 'duck', 'pig', 'bee', 'goat', 'crab', 'fish', 'chicken', 'horse', 'llama', 'camel', 'sheep'] - - const potentialUsernames = [] - for (let i = 0; i < 10; i++) { - const potentialUsername = `${adjectives[randomInt(adjectives.length)]}-${colors[randomInt(colors.length)]}-${nouns[randomInt(nouns.length)]}-${randomInt(100_000)}` - potentialUsernames.push(potentialUsername) - } - - return findAvailableUsername(potentialUsernames) -} - -// Generates an unused username based on an array of username segments and a separator. -// It generates several options and ensures it picks one that is not currently in use. -export function generateAvailableUsername(usernameSegments, config) { - const separator = config?.separator || '-' - const baseUsername = usernameSegments.join(separator) - - const potentialUsernames = [] - for (let i = 0; i < 10; i++) { - const potentialUsername = `${baseUsername}${separator}${randomInt(100_000)}` - potentialUsernames.push(potentialUsername) - } - - return findAvailableUsername(potentialUsernames) -} - -// Checks the database for an unused username from an array provided and returns first. -async function findAvailableUsername(potentialUsernames) { - const users = await prisma.{= userEntityLower =}.findMany({ - where: { - username: { in: potentialUsernames }, - } - }) - const takenUsernames = users.map(user => user.username) - const availableUsernames = potentialUsernames.filter(username => !takenUsernames.includes(username)) - - if (availableUsernames.length === 0) { - throw new Error('Unable to generate a unique username. Please contact Wasp.') - } - - return availableUsernames[0] -} - -export function throwInvalidCredentialsError(message) { - throw new HttpError(401, 'Invalid credentials', { message }) -} - -export default auth diff --git a/waspc/data/Generator/templates/server/src/crud/_operations.ts b/waspc/data/Generator/templates/server/src/crud/_operations.ts index 7ffb5a1fd2..0185a6cfd7 100644 --- a/waspc/data/Generator/templates/server/src/crud/_operations.ts +++ b/waspc/data/Generator/templates/server/src/crud/_operations.ts @@ -1,5 +1,5 @@ {{={= =}=}} -import prisma from "../dbClient.js"; +import prisma from "wasp/server/dbClient"; import type { {=# isAuthEnabled =} @@ -18,7 +18,7 @@ import type { {= crud.entityUpper =}, } from "../entities"; {=# isAuthEnabled =} -import { throwInvalidCredentialsError } from "../core/auth.js"; +import { throwInvalidCredentialsError } from '../auth/utils.js' {=/ isAuthEnabled =} {=# overrides.GetAll.isDefined =} {=& overrides.GetAll.importStatement =} diff --git a/waspc/data/Generator/templates/server/src/dbSeed.ts b/waspc/data/Generator/templates/server/src/dbSeed.ts index a4d89ddd6b..1f4d840781 100644 --- a/waspc/data/Generator/templates/server/src/dbSeed.ts +++ b/waspc/data/Generator/templates/server/src/dbSeed.ts @@ -6,7 +6,7 @@ // TODO: Consider in the future moving it into a a separate project (maybe db/ ?), while still // maintaining access to logic from the server/ . -import prismaClient from './dbClient.js' +import prismaClient from 'wasp/server/dbClient' import type { DbSeedFn } from './dbSeed/types.js' {=# dbSeeds =} diff --git a/waspc/data/Generator/templates/server/src/email/core/index.ts b/waspc/data/Generator/templates/server/src/email/core/index.ts index 9844ed3962..78430344b7 100644 --- a/waspc/data/Generator/templates/server/src/email/core/index.ts +++ b/waspc/data/Generator/templates/server/src/email/core/index.ts @@ -8,3 +8,6 @@ export { initSendGridEmailSender as initEmailSender } from "./providers/sendgrid {=# isMailgunProviderUsed =} export { initMailgunEmailSender as initEmailSender } from "./providers/mailgun.js"; {=/ isMailgunProviderUsed =} +{=# isDummyProviderUsed =} +export { initDummyEmailSender as initEmailSender } from "./providers/dummy.js"; +{=/ isDummyProviderUsed =} diff --git a/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts b/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts index b4b3ef0450..f7b244e873 100644 --- a/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts +++ b/waspc/data/Generator/templates/server/src/email/core/providers/dummy.ts @@ -1,21 +1,28 @@ -import { EmailSender } from "../types.js"; +import { DummyEmailProvider, EmailSender } from "../types.js"; import { getDefaultFromField } from "../helpers.js"; -export function initDummyEmailSender(): EmailSender { +const yellowColor = "\x1b[33m%s\x1b[0m"; + +export function initDummyEmailSender( + config?: DummyEmailProvider, +): EmailSender { const defaultFromField = getDefaultFromField(); return { send: async (email) => { const fromField = email.from || defaultFromField; - console.log('Test email (not sent):', { - from: { - email: fromField.email, - name: fromField.name, - }, - to: email.to, - subject: email.subject, - text: email.text, - html: email.html, - }); + + console.log(yellowColor, '╔═══════════════════════╗'); + console.log(yellowColor, '║ Dummy email sender ✉️ ║'); + console.log(yellowColor, '╚═══════════════════════╝'); + console.log(`From: ${fromField.name} <${fromField.email}>`); + console.log(`To: ${email.to}`); + console.log(`Subject: ${email.subject}`); + console.log(yellowColor, '═════════ Text ═════════'); + console.log(email.text); + console.log(yellowColor, '═════════ HTML ═════════'); + console.log(email.html); + console.log(yellowColor, '════════════════════════'); + return { success: true, }; diff --git a/waspc/data/Generator/templates/server/src/email/core/types.ts b/waspc/data/Generator/templates/server/src/email/core/types.ts index a86b26b3ba..8b2a984799 100644 --- a/waspc/data/Generator/templates/server/src/email/core/types.ts +++ b/waspc/data/Generator/templates/server/src/email/core/types.ts @@ -1,5 +1,5 @@ {{={= =}=}} -export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider; +export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider | DummyEmailProvider; export type SMTPEmailProvider = { type: "smtp"; @@ -20,6 +20,10 @@ export type MailgunEmailProvider = { domain: string; }; +export type DummyEmailProvider = { + type: "dummy"; +} + export type EmailSender = { send: (email: Email) => Promise; }; diff --git a/waspc/data/Generator/templates/server/src/email/index.ts b/waspc/data/Generator/templates/server/src/email/index.ts index a4467c43f2..3ed1f35b2b 100644 --- a/waspc/data/Generator/templates/server/src/email/index.ts +++ b/waspc/data/Generator/templates/server/src/email/index.ts @@ -1,9 +1,6 @@ {{={= =}=}} import { initEmailSender } from "./core/index.js"; -import waspServerConfig from '../config.js'; -import { initDummyEmailSender } from "./core/providers/dummy.js"; - {=# isSmtpProviderUsed =} const emailProvider = { type: "smtp", @@ -26,10 +23,10 @@ const emailProvider = { domain: process.env.MAILGUN_DOMAIN, } as const; {=/ isMailgunProviderUsed =} +{=# isDummyProviderUsed =} +const emailProvider = { + type: "dummy", +} as const; +{=/ isDummyProviderUsed =} -const areEmailsSentInDevelopment = process.env.SEND_EMAILS_IN_DEVELOPMENT === "true"; -const isDummyEmailSenderUsed = waspServerConfig.isDevelopment && !areEmailsSentInDevelopment; - -export const emailSender = isDummyEmailSenderUsed - ? initDummyEmailSender() - : initEmailSender(emailProvider); \ No newline at end of file +export const emailSender = initEmailSender(emailProvider); diff --git a/waspc/data/Generator/templates/server/src/jobs/_job.ts b/waspc/data/Generator/templates/server/src/jobs/_job.ts index ef619f17d8..21a30a45f3 100644 --- a/waspc/data/Generator/templates/server/src/jobs/_job.ts +++ b/waspc/data/Generator/templates/server/src/jobs/_job.ts @@ -1,5 +1,5 @@ {{={= =}=}} -import prisma from '../dbClient.js' +import prisma from 'wasp/server/dbClient' import type { JSONValue, JSONObject } from '../_types/serialization.js' import { createJob, type JobFn } from './{= jobExecutorRelativePath =}' {=& jobPerformFnImportStatement =} diff --git a/waspc/data/Generator/templates/server/src/middleware/globalMiddleware.ts b/waspc/data/Generator/templates/server/src/middleware/globalMiddleware.ts index cf274f4d8b..be6fec4dff 100644 --- a/waspc/data/Generator/templates/server/src/middleware/globalMiddleware.ts +++ b/waspc/data/Generator/templates/server/src/middleware/globalMiddleware.ts @@ -5,7 +5,7 @@ import logger from 'morgan' import cors from 'cors' import helmet from 'helmet' -import config from '../config.js' +import config from 'wasp/server/config' {=# globalMiddlewareConfigFn.isDefined =} {=& globalMiddlewareConfigFn.importStatement =} diff --git a/waspc/data/Generator/templates/server/src/middleware/operations.ts b/waspc/data/Generator/templates/server/src/middleware/operations.ts index 677ac113c8..856256c79a 100644 --- a/waspc/data/Generator/templates/server/src/middleware/operations.ts +++ b/waspc/data/Generator/templates/server/src/middleware/operations.ts @@ -3,7 +3,7 @@ import { deserialize as superjsonDeserialize, serialize as superjsonSerialize, } from 'superjson' -import { handleRejection } from '../utils.js' +import { handleRejection } from 'wasp/server/utils' export function createOperation (handlerFn) { return handleRejection(async (req, res) => { diff --git a/waspc/data/Generator/templates/server/src/polyfill.ts b/waspc/data/Generator/templates/server/src/polyfill.ts new file mode 100644 index 0000000000..a59302451a --- /dev/null +++ b/waspc/data/Generator/templates/server/src/polyfill.ts @@ -0,0 +1,7 @@ +// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it +// for random number generation. + +import { webcrypto } from "node:crypto"; + +// @ts-ignore +globalThis.crypto = webcrypto as Crypto; diff --git a/waspc/data/Generator/templates/server/src/queries/_query.ts b/waspc/data/Generator/templates/server/src/queries/_query.ts index 2d6c17d482..24118fd432 100644 --- a/waspc/data/Generator/templates/server/src/queries/_query.ts +++ b/waspc/data/Generator/templates/server/src/queries/_query.ts @@ -1,5 +1,5 @@ {{={= =}=}} -import prisma from '../dbClient.js' +import prisma from 'wasp/server/dbClient' {=& jsFn.importStatement =} diff --git a/waspc/data/Generator/templates/server/src/queries/types.ts b/waspc/data/Generator/templates/server/src/queries/types.ts index 405b05d9c8..33055e7c74 100644 --- a/waspc/data/Generator/templates/server/src/queries/types.ts +++ b/waspc/data/Generator/templates/server/src/queries/types.ts @@ -13,7 +13,7 @@ import { type AuthenticatedQuery, {=/ shouldImportAuthenticatedOperation =} type Payload, -} from '../_types' +} from 'wasp/server/_types' {=# operations =} export type {= typeName =} = diff --git a/waspc/data/Generator/templates/server/src/routes/apis/index.ts b/waspc/data/Generator/templates/server/src/routes/apis/index.ts index 5c928f81dc..fe18c87761 100644 --- a/waspc/data/Generator/templates/server/src/routes/apis/index.ts +++ b/waspc/data/Generator/templates/server/src/routes/apis/index.ts @@ -1,10 +1,10 @@ {{={= =}=}} import express from 'express' -import prisma from '../../dbClient.js' -import { handleRejection } from '../../utils.js' +import prisma from 'wasp/server/dbClient' +import { handleRejection } from 'wasp/server/utils' import { MiddlewareConfigFn, globalMiddlewareConfigForExpress } from '../../middleware/index.js' {=# isAuthEnabled =} -import auth from '../../core/auth.js' +import auth from 'wasp/core/auth' import { type SanitizedUser } from '../../_types' {=/ isAuthEnabled =} diff --git a/waspc/data/Generator/templates/server/src/routes/auth/index.js b/waspc/data/Generator/templates/server/src/routes/auth/index.js index f0feee2cd2..da3bc79016 100644 --- a/waspc/data/Generator/templates/server/src/routes/auth/index.js +++ b/waspc/data/Generator/templates/server/src/routes/auth/index.js @@ -1,14 +1,16 @@ {{={= =}=}} import express from 'express' -import auth from '../../core/auth.js' +import auth from 'wasp/core/auth' import me from './me.js' +import logout from './logout.js' import providersRouter from '../../auth/providers/index.js' const router = express.Router() router.get('/me', auth, me) +router.post('/logout', auth, logout) router.use('/', providersRouter) export default router diff --git a/waspc/data/Generator/templates/server/src/routes/auth/logout.ts b/waspc/data/Generator/templates/server/src/routes/auth/logout.ts new file mode 100644 index 0000000000..83476bfc2f --- /dev/null +++ b/waspc/data/Generator/templates/server/src/routes/auth/logout.ts @@ -0,0 +1,12 @@ +import { handleRejection } from 'wasp/server/utils' +import { throwInvalidCredentialsError } from '../../auth/utils.js' +import { invalidateSession } from '../../auth/session.js' + +export default handleRejection(async (req, res) => { + if (req.sessionId) { + await invalidateSession(req.sessionId) + return res.json({ success: true }) + } else { + throwInvalidCredentialsError() + } +}) diff --git a/waspc/data/Generator/templates/server/src/routes/auth/me.js b/waspc/data/Generator/templates/server/src/routes/auth/me.js index acffd77f18..63b1b13feb 100644 --- a/waspc/data/Generator/templates/server/src/routes/auth/me.js +++ b/waspc/data/Generator/templates/server/src/routes/auth/me.js @@ -1,7 +1,6 @@ -{{={= =}=}} import { serialize as superjsonSerialize } from 'superjson' -import { handleRejection } from '../../utils.js' -import { throwInvalidCredentialsError } from '../../core/auth.js' +import { handleRejection } from 'wasp/server/utils' +import { throwInvalidCredentialsError } from '../../auth/utils.js' export default handleRejection(async (req, res) => { if (req.user) { diff --git a/waspc/data/Generator/templates/server/src/routes/crud/_crud.ts b/waspc/data/Generator/templates/server/src/routes/crud/_crud.ts index 72d860e7e3..a0cdf9efcd 100644 --- a/waspc/data/Generator/templates/server/src/routes/crud/_crud.ts +++ b/waspc/data/Generator/templates/server/src/routes/crud/_crud.ts @@ -3,7 +3,7 @@ import express from 'express' import * as crud from '../../crud/{= crud.name =}.js' import { createAction, createQuery } from '../../middleware/operations.js' {=# isAuthEnabled =} -import auth from '../../core/auth.js' +import auth from 'wasp/core/auth' {=/ isAuthEnabled =} const _waspRouter = express.Router() diff --git a/waspc/data/Generator/templates/server/src/routes/operations/index.js b/waspc/data/Generator/templates/server/src/routes/operations/index.js index 747e43af86..394cb78a7f 100644 --- a/waspc/data/Generator/templates/server/src/routes/operations/index.js +++ b/waspc/data/Generator/templates/server/src/routes/operations/index.js @@ -2,7 +2,7 @@ import express from 'express' {=# isAuthEnabled =} -import auth from '../../core/auth.js' +import auth from 'wasp/core/auth' {=/ isAuthEnabled =} {=# operationRoutes =} diff --git a/waspc/data/Generator/templates/server/src/server.ts b/waspc/data/Generator/templates/server/src/server.ts index 92d166a728..858011c482 100644 --- a/waspc/data/Generator/templates/server/src/server.ts +++ b/waspc/data/Generator/templates/server/src/server.ts @@ -2,7 +2,7 @@ import http from 'http' import app from './app.js' -import config from './config.js' +import config from 'wasp/server/config' {=# setupFn.isDefined =} {=& setupFn.importStatement =} @@ -18,6 +18,8 @@ import './jobs/core/allJobs.js' import { init as initWebSocket } from './webSocket/initialization.js' {=/ userWebSocketFn.isDefined =} +import './polyfill.js' + const startServer = async () => { {=# isPgBossJobExecutorUsed =} await startPgBoss() diff --git a/waspc/data/Generator/templates/server/src/types/index.ts b/waspc/data/Generator/templates/server/src/types/index.ts index 93313219e1..205014216e 100644 --- a/waspc/data/Generator/templates/server/src/types/index.ts +++ b/waspc/data/Generator/templates/server/src/types/index.ts @@ -13,10 +13,6 @@ export type ServerSetupFnContext = { export type { Application } from 'express' export type { Server } from 'http' -{=# isExternalAuthEnabled =} -export type { GetUserFieldsFn } from '../auth/providers/oauth/types'; -{=/ isExternalAuthEnabled =} - {=# isEmailAuthEnabled =} export type { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from '../auth/providers/email/types'; {=/ isEmailAuthEnabled =} diff --git a/waspc/data/Generator/templates/server/src/utils.ts b/waspc/data/Generator/templates/server/src/utils.ts index 3a38f87ede..6ca262decd 100644 --- a/waspc/data/Generator/templates/server/src/utils.ts +++ b/waspc/data/Generator/templates/server/src/utils.ts @@ -7,12 +7,13 @@ import { dirname } from 'path' import { fileURLToPath } from 'url' {=# isAuthEnabled =} -import { type SanitizedUser } from './_types/index.js' +import { type SanitizedUser } from 'wasp/server/_types/index.js' {=/ isAuthEnabled =} type RequestWithExtraFields = Request & { {=# isAuthEnabled =} - user?: SanitizedUser + user?: SanitizedUser; + sessionId?: string; {=/ isAuthEnabled =} } diff --git a/waspc/data/Generator/templates/server/src/webSocket/index.ts b/waspc/data/Generator/templates/server/src/webSocket/index.ts index 3393b500bc..e3cdd9d620 100644 --- a/waspc/data/Generator/templates/server/src/webSocket/index.ts +++ b/waspc/data/Generator/templates/server/src/webSocket/index.ts @@ -3,7 +3,7 @@ import { Server } from 'socket.io' import { EventsMap, DefaultEventsMap } from '@socket.io/component-emitter' -import prisma from '../dbClient.js' +import prisma from 'wasp/server/dbClient' {=# isAuthEnabled =} import { type SanitizedUser } from '../_types/index.js' {=/ isAuthEnabled =} diff --git a/waspc/data/Generator/templates/server/src/webSocket/initialization.ts b/waspc/data/Generator/templates/server/src/webSocket/initialization.ts index f467d0de7d..5f6555c6e6 100644 --- a/waspc/data/Generator/templates/server/src/webSocket/initialization.ts +++ b/waspc/data/Generator/templates/server/src/webSocket/initialization.ts @@ -5,10 +5,10 @@ import { Server, Socket } from 'socket.io' import type { ServerType } from './index.js' import config from '../config.js' -import prisma from '../dbClient.js' +import prisma from 'wasp/server/dbClient' {=# isAuthEnabled =} -import { getUserFromToken } from '../core/auth.js' +import { getSessionAndUserFromSessionId } from '../auth/session.js' {=/ isAuthEnabled =} {=& userWebSocketFn.importStatement =} @@ -40,10 +40,11 @@ export async function init(server: http.Server): Promise { {=# isAuthEnabled =} async function addUserToSocketDataIfAuthenticated(socket: Socket, next: (err?: Error) => void) { - const token = socket.handshake.auth.token - if (token) { + const sessionId = socket.handshake.auth.sessionId + if (sessionId) { try { - socket.data = { ...socket.data, user: await getUserFromToken(token) } + const { user } = await getSessionAndUserFromSessionId(sessionId) + socket.data = { ...socket.data, user } } catch (err) { } } next() diff --git a/waspc/data/Generator/templates/server/tsconfig.json b/waspc/data/Generator/templates/server/tsconfig.json index 6713168c4a..14433f8119 100644 --- a/waspc/data/Generator/templates/server/tsconfig.json +++ b/waspc/data/Generator/templates/server/tsconfig.json @@ -3,6 +3,15 @@ "extends": "@tsconfig/node{= majorNodeVersion =}/tsconfig.json", "compilerOptions": { // Overriding this until we implement more complete TypeScript support. + // Filip: begin client file hacks + // We need this to make server work with copied client files (we copy everything) + "jsx": "preserve", + "lib": [ + "esnext", + "dom", + "DOM.Iterable" + ], + // Filip: end client file hacks "strict": false, // Overriding this because we want to use top-level await "module": "esnext", @@ -11,12 +20,13 @@ "sourceMap": true, // The remaining settings should match the extended nodeXY/tsconfig.json, but I kept // them here to be explicit. - // Enable default imports in TypeScript. "esModuleInterop": true, "moduleResolution": "node", "outDir": "dist", "allowJs": true }, - "include": ["src"] -} + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/waspc/e2e-test/ShellCommands.hs b/waspc/e2e-test/ShellCommands.hs index 6b529161e4..6700e540bb 100644 --- a/waspc/e2e-test/ShellCommands.hs +++ b/waspc/e2e-test/ShellCommands.hs @@ -1,5 +1,4 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE InstanceSigs #-} module ShellCommands ( ShellCommand, diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest index 412fac0d7d..5080925b79 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest @@ -23,6 +23,7 @@ waspBuild/.wasp/build/server/src/entities/index.ts waspBuild/.wasp/build/server/src/middleware/globalMiddleware.ts waspBuild/.wasp/build/server/src/middleware/index.ts waspBuild/.wasp/build/server/src/middleware/operations.ts +waspBuild/.wasp/build/server/src/polyfill.ts waspBuild/.wasp/build/server/src/queries/types.ts waspBuild/.wasp/build/server/src/routes/index.js waspBuild/.wasp/build/server/src/routes/operations/index.js diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums index 17b04a8a9d..6b56c32fc1 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums @@ -167,6 +167,13 @@ ], "864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07" ], + [ + [ + "file", + "server/src/polyfill.ts" + ], + "66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf" + ], [ [ "file", @@ -193,14 +200,14 @@ "file", "server/src/server.ts" ], - "1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be" + "d0666b659cdc75db181ea2bbb50c4e157f0a7fbe00c4ff8fda0933b1a13e5a0e" ], [ [ "file", "server/src/types/index.ts" ], - "1958cfc3e3b5f59490168797e4b8dcdc38f32346e734f90df3fb6baa264b36b5" + "f7621082fc7d8467a0967eb0bd82ff7956052b766e9e82d50584b8de88e0d28a" ], [ [ @@ -319,21 +326,21 @@ "file", "web-app/src/actions/index.ts" ], - "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092" + "607c3311861456ae47c246a950c8e29593f9837a9f5c48923d99cd7fac1ce0bb" ], [ [ "file", "web-app/src/api.ts" ], - "93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf" + "850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104" ], [ [ "file", "web-app/src/api/events.ts" ], - "7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044" + "91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/polyfill.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/polyfill.ts new file mode 100644 index 0000000000..a59302451a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/polyfill.ts @@ -0,0 +1,7 @@ +// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it +// for random number generation. + +import { webcrypto } from "node:crypto"; + +// @ts-ignore +globalThis.crypto = webcrypto as Crypto; diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/server.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/server.ts index 1df46663f5..fff57f199b 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/server.ts +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/server.ts @@ -6,6 +6,8 @@ import config from './config.js' +import './polyfill.js' + const startServer = async () => { const port = normalizePort(config.port) diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts index 9540b5dddc..b30c92f5ec 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/types/index.ts @@ -12,4 +12,3 @@ export type ServerSetupFnContext = { export type { Application } from 'express' export type { Server } from 'http' - diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts index 5e4dfedd12..7fb2de2f9e 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/actions/index.ts @@ -42,7 +42,7 @@ export type UpdateQuery = (item: ActionInput, oldData: /** * A public query specifier used for addressing Wasp queries. See our docs for details: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates */ export type QuerySpecifier = [Query, ...any[]] @@ -116,7 +116,7 @@ type InternalAction = Action & { * * @param publicOptimisticUpdateDefinition An optimistic update definition * object that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns An internally-used optimistic update definition object. */ function translateToInternalDefinition( @@ -260,7 +260,7 @@ function getOptimisticUpdateDefinitionForSpecificItem( * Translates a Wasp query specifier to a query cache key used by React Query. * * @param querySpecifier A query specifier that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns A cache key React Query internally uses for addressing queries. */ function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier): QueryKey { diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.ts index d7532f65c6..17e36c1248 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.ts +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api.ts @@ -8,59 +8,60 @@ const api = axios.create({ baseURL: config.apiUrl, }) -const WASP_APP_AUTH_TOKEN_NAME = 'authToken' +const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId' -let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined +let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined -export function setAuthToken(token: string): void { - authToken = token - storage.set(WASP_APP_AUTH_TOKEN_NAME, token) - apiEventsEmitter.emit('authToken.set') +export function setSessionId(sessionId: string): void { + waspAppAuthSessionId = sessionId + storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId) + apiEventsEmitter.emit('sessionId.set') } -export function getAuthToken(): string | undefined { - return authToken +export function getSessionId(): string | undefined { + return waspAppAuthSessionId } -export function clearAuthToken(): void { - authToken = undefined - storage.remove(WASP_APP_AUTH_TOKEN_NAME) - apiEventsEmitter.emit('authToken.clear') +export function clearSessionId(): void { + waspAppAuthSessionId = undefined + storage.remove(WASP_APP_AUTH_SESSION_ID_NAME) + apiEventsEmitter.emit('sessionId.clear') } export function removeLocalUserData(): void { - authToken = undefined + waspAppAuthSessionId = undefined storage.clear() - apiEventsEmitter.emit('authToken.clear') + apiEventsEmitter.emit('sessionId.clear') } api.interceptors.request.use((request) => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` + const sessionId = getSessionId() + if (sessionId) { + request.headers['Authorization'] = `Bearer ${sessionId}` } return request }) api.interceptors.response.use(undefined, (error) => { if (error.response?.status === 401) { - clearAuthToken() + clearSessionId() } return Promise.reject(error) }) // This handler will run on other tabs (not the active one calling API functions), -// and will ensure they know about auth token changes. +// and will ensure they know about auth session ID changes. // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event // "Note: This won't work on the same page that is making the changes — it is really a way // for other pages on the domain using the storage to sync any changes that are made." window.addEventListener('storage', (event) => { - if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) { + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { if (!!event.newValue) { - authToken = event.newValue - apiEventsEmitter.emit('authToken.set') + waspAppAuthSessionId = event.newValue + apiEventsEmitter.emit('sessionId.set') } else { - authToken = undefined - apiEventsEmitter.emit('authToken.clear') + waspAppAuthSessionId = undefined + apiEventsEmitter.emit('sessionId.clear') } } }) diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api/events.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api/events.ts index 9a59b366d3..a72e48dda8 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api/events.ts +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/api/events.ts @@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt'; type ApiEvents = { // key: Event name // type: Event payload type - 'authToken.set': void; - 'authToken.clear': void; + 'sessionId.set': void; + 'sessionId.clear': void; }; -// Used to allow API clients to register for auth token change events. +// Used to allow API clients to register for auth session ID change events. export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest index 0658a077b2..3c7ea01ae8 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest @@ -24,6 +24,7 @@ waspCompile/.wasp/out/server/src/entities/index.ts waspCompile/.wasp/out/server/src/middleware/globalMiddleware.ts waspCompile/.wasp/out/server/src/middleware/index.ts waspCompile/.wasp/out/server/src/middleware/operations.ts +waspCompile/.wasp/out/server/src/polyfill.ts waspCompile/.wasp/out/server/src/queries/types.ts waspCompile/.wasp/out/server/src/routes/index.js waspCompile/.wasp/out/server/src/routes/operations/index.js diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums index 6627b3d2d7..cb531d0c2e 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums @@ -174,6 +174,13 @@ ], "864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07" ], + [ + [ + "file", + "server/src/polyfill.ts" + ], + "66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf" + ], [ [ "file", @@ -200,14 +207,14 @@ "file", "server/src/server.ts" ], - "1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be" + "d0666b659cdc75db181ea2bbb50c4e157f0a7fbe00c4ff8fda0933b1a13e5a0e" ], [ [ "file", "server/src/types/index.ts" ], - "1958cfc3e3b5f59490168797e4b8dcdc38f32346e734f90df3fb6baa264b36b5" + "f7621082fc7d8467a0967eb0bd82ff7956052b766e9e82d50584b8de88e0d28a" ], [ [ @@ -333,21 +340,21 @@ "file", "web-app/src/actions/index.ts" ], - "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092" + "607c3311861456ae47c246a950c8e29593f9837a9f5c48923d99cd7fac1ce0bb" ], [ [ "file", "web-app/src/api.ts" ], - "93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf" + "850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104" ], [ [ "file", "web-app/src/api/events.ts" ], - "7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044" + "91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/polyfill.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/polyfill.ts new file mode 100644 index 0000000000..a59302451a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/polyfill.ts @@ -0,0 +1,7 @@ +// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it +// for random number generation. + +import { webcrypto } from "node:crypto"; + +// @ts-ignore +globalThis.crypto = webcrypto as Crypto; diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/server.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/server.ts index 1df46663f5..fff57f199b 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/server.ts +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/server.ts @@ -6,6 +6,8 @@ import config from './config.js' +import './polyfill.js' + const startServer = async () => { const port = normalizePort(config.port) diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts index 9540b5dddc..b30c92f5ec 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/types/index.ts @@ -12,4 +12,3 @@ export type ServerSetupFnContext = { export type { Application } from 'express' export type { Server } from 'http' - diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts index 5e4dfedd12..7fb2de2f9e 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/actions/index.ts @@ -42,7 +42,7 @@ export type UpdateQuery = (item: ActionInput, oldData: /** * A public query specifier used for addressing Wasp queries. See our docs for details: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates */ export type QuerySpecifier = [Query, ...any[]] @@ -116,7 +116,7 @@ type InternalAction = Action & { * * @param publicOptimisticUpdateDefinition An optimistic update definition * object that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns An internally-used optimistic update definition object. */ function translateToInternalDefinition( @@ -260,7 +260,7 @@ function getOptimisticUpdateDefinitionForSpecificItem( * Translates a Wasp query specifier to a query cache key used by React Query. * * @param querySpecifier A query specifier that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns A cache key React Query internally uses for addressing queries. */ function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier): QueryKey { diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.ts index d7532f65c6..17e36c1248 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.ts +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api.ts @@ -8,59 +8,60 @@ const api = axios.create({ baseURL: config.apiUrl, }) -const WASP_APP_AUTH_TOKEN_NAME = 'authToken' +const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId' -let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined +let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined -export function setAuthToken(token: string): void { - authToken = token - storage.set(WASP_APP_AUTH_TOKEN_NAME, token) - apiEventsEmitter.emit('authToken.set') +export function setSessionId(sessionId: string): void { + waspAppAuthSessionId = sessionId + storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId) + apiEventsEmitter.emit('sessionId.set') } -export function getAuthToken(): string | undefined { - return authToken +export function getSessionId(): string | undefined { + return waspAppAuthSessionId } -export function clearAuthToken(): void { - authToken = undefined - storage.remove(WASP_APP_AUTH_TOKEN_NAME) - apiEventsEmitter.emit('authToken.clear') +export function clearSessionId(): void { + waspAppAuthSessionId = undefined + storage.remove(WASP_APP_AUTH_SESSION_ID_NAME) + apiEventsEmitter.emit('sessionId.clear') } export function removeLocalUserData(): void { - authToken = undefined + waspAppAuthSessionId = undefined storage.clear() - apiEventsEmitter.emit('authToken.clear') + apiEventsEmitter.emit('sessionId.clear') } api.interceptors.request.use((request) => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` + const sessionId = getSessionId() + if (sessionId) { + request.headers['Authorization'] = `Bearer ${sessionId}` } return request }) api.interceptors.response.use(undefined, (error) => { if (error.response?.status === 401) { - clearAuthToken() + clearSessionId() } return Promise.reject(error) }) // This handler will run on other tabs (not the active one calling API functions), -// and will ensure they know about auth token changes. +// and will ensure they know about auth session ID changes. // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event // "Note: This won't work on the same page that is making the changes — it is really a way // for other pages on the domain using the storage to sync any changes that are made." window.addEventListener('storage', (event) => { - if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) { + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { if (!!event.newValue) { - authToken = event.newValue - apiEventsEmitter.emit('authToken.set') + waspAppAuthSessionId = event.newValue + apiEventsEmitter.emit('sessionId.set') } else { - authToken = undefined - apiEventsEmitter.emit('authToken.clear') + waspAppAuthSessionId = undefined + apiEventsEmitter.emit('sessionId.clear') } } }) diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api/events.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api/events.ts index 9a59b366d3..a72e48dda8 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api/events.ts +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/api/events.ts @@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt'; type ApiEvents = { // key: Event name // type: Event payload type - 'authToken.set': void; - 'authToken.clear': void; + 'sessionId.set': void; + 'sessionId.clear': void; }; -// Used to allow API clients to register for auth token change events. +// Used to allow API clients to register for auth session ID change events. export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest index af31ebb1a2..ee6b2469e7 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest @@ -21,12 +21,16 @@ waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.ts waspComplexTest/.wasp/out/server/src/actions/types.ts waspComplexTest/.wasp/out/server/src/apis/types.ts waspComplexTest/.wasp/out/server/src/app.js +waspComplexTest/.wasp/out/server/src/auth/jwt.ts +waspComplexTest/.wasp/out/server/src/auth/lucia.ts +waspComplexTest/.wasp/out/server/src/auth/password.ts waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts waspComplexTest/.wasp/out/server/src/auth/providers/index.ts waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts waspComplexTest/.wasp/out/server/src/auth/providers/types.ts +waspComplexTest/.wasp/out/server/src/auth/session.ts waspComplexTest/.wasp/out/server/src/auth/user.ts waspComplexTest/.wasp/out/server/src/auth/utils.ts waspComplexTest/.wasp/out/server/src/auth/validation.ts @@ -39,7 +43,6 @@ waspComplexTest/.wasp/out/server/src/dbClient.ts waspComplexTest/.wasp/out/server/src/dbSeed/types.ts waspComplexTest/.wasp/out/server/src/email/core/helpers.ts waspComplexTest/.wasp/out/server/src/email/core/index.ts -waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts waspComplexTest/.wasp/out/server/src/email/core/providers/sendgrid.ts waspComplexTest/.wasp/out/server/src/email/core/types.ts waspComplexTest/.wasp/out/server/src/email/index.ts @@ -60,10 +63,12 @@ waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.ts waspComplexTest/.wasp/out/server/src/middleware/globalMiddleware.ts waspComplexTest/.wasp/out/server/src/middleware/index.ts waspComplexTest/.wasp/out/server/src/middleware/operations.ts +waspComplexTest/.wasp/out/server/src/polyfill.ts waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.ts waspComplexTest/.wasp/out/server/src/queries/types.ts waspComplexTest/.wasp/out/server/src/routes/apis/index.ts waspComplexTest/.wasp/out/server/src/routes/auth/index.js +waspComplexTest/.wasp/out/server/src/routes/auth/logout.ts waspComplexTest/.wasp/out/server/src/routes/auth/me.js waspComplexTest/.wasp/out/server/src/routes/crud/index.ts waspComplexTest/.wasp/out/server/src/routes/crud/tasks.ts diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums index b4ff5d3eac..cf657f8095 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums @@ -18,7 +18,7 @@ "file", "db/schema.prisma" ], - "3e6bfc3dadfc9399a3fae3ea77cadb1aa3259efa0b8362ed77d6d5ee5013d393" + "5a41561cd6f853da2f4b9f64267f5d4e3b2865f94dfa92d9143acf2c1485519c" ], [ [ @@ -60,7 +60,7 @@ "file", "server/package.json" ], - "53aef3360681af1936a5bb2a3f7fa1c7cb74f58f398942bb8bd979a9b78e9b16" + "831f2505c29201cd8e5076fda768423448a6cbba92410b0692fd8ac630aeb76b" ], [ [ @@ -88,7 +88,7 @@ "file", "server/src/_types/index.ts" ], - "92027caebe484c7d412f97be9bc3c39257d10f9f2bc2e447e52c6855d5d30ffc" + "e7678be590a77799133e99d07a3326bbc6e627a5a219db8714a1b3d3b3304150" ], [ [ @@ -132,12 +132,33 @@ ], "86504ede1daeca35cc93f665ca8ac2fdf46ecaff02f6d3a7810a14d2bc71e16a" ], + [ + [ + "file", + "server/src/auth/jwt.ts" + ], + "be637e3c0ac601c1b6edb4a5cc6a9300edfb7fb1568220672254524a6b395b89" + ], + [ + [ + "file", + "server/src/auth/lucia.ts" + ], + "dc5e75c3e3da677c8e98d1456964f1f9bce9105bf90f3c351379e54d3433523b" + ], + [ + [ + "file", + "server/src/auth/password.ts" + ], + "f9003f11bf1396bff69a98440543e6e131f50d5bc1269a8aae55d72615922705" + ], [ [ "file", "server/src/auth/providers/config/google.ts" ], - "62e519ae90c87e1032e53d089a8d6106331a278ecd1293767c8e8e9cb4848f6a" + "3b6483ca304c77002f5cd4475ed8cb11617d210bfab169d85b82effc82836da8" ], [ [ @@ -151,42 +172,49 @@ "file", "server/src/auth/providers/oauth/createRouter.ts" ], - "da122a8a244ddbd9b84bba72a7da2f32ffa41c6093614c6c0d59e113244d2bbc" + "76e0bd6e17cdcea8c418c2577be8f39fa20db8d208eedafef679a1aee9cd1dae" ], [ [ "file", "server/src/auth/providers/oauth/init.ts" ], - "cef00c764f6c6923c0138f114eaf0484ad30c4e9dde7f6b44a143061909a8ba1" + "e844a8225699db6d44cc59fb842c1142f44cfd53ee431aa6738c9971001b412b" ], [ [ "file", "server/src/auth/providers/oauth/types.ts" ], - "00c951bd5dae77b7aedca90c0847f6e861e7f151e89b1906e794469981191b47" + "9f1561dae6c728a2e93938e49cc4c51f11d332b74f7cf3ad6634acaf91bbd634" ], [ [ "file", "server/src/auth/providers/types.ts" ], - "b647575a04eeb7824d95082a461d59763d034dc7d03a8fbcdd25143b6f8431b6" + "72cf71573f3ae1b5f49c7019001781a13f8617772d07926375ed6966164d4597" + ], + [ + [ + "file", + "server/src/auth/session.ts" + ], + "6edca533dbb38ddcba326fd0399dc6f5bc9b249327ed61c7c167d3a5a9d9a462" ], [ [ "file", "server/src/auth/user.ts" ], - "5787f3cdab4739781090f2950ba432dca812483ec23c6319ac3f876118324d15" + "74080241b9011ddce54ac2452b7c8a7064a4070ef1b04e420c730c62d787e077" ], [ [ "file", "server/src/auth/utils.ts" ], - "ba76300456ffdbd647923b27ff163df5e3efa016d7cd2a01af0b6a86dcd780a9" + "9022854d0aa165227abb25a1f8c0a2c3985ce2ce1d624ebe807749dbaef94091" ], [ [ @@ -221,14 +249,14 @@ "file", "server/src/core/auth.js" ], - "d708303af170e8159b93f0dda521b6f622c0f3add2d4f4f8f2fd88c0a4f7b79e" + "c6563934d31312dc92af13ebdac56d30a4672c7419be8a72f5666e8e4635f653" ], [ [ "file", "server/src/crud/tasks.ts" ], - "2c4e1f94939adf825df14624940019889394a0e56cdea2855686d67e0c08458a" + "3dbc7ee4341bc00af125c02d4b771bc7e7fb5bc7063191d659e7243217e2761f" ], [ [ @@ -258,13 +286,6 @@ ], "d524dd9ef27cd311340060411276df0e8ef22db503473f44281832338b954bb7" ], - [ - [ - "file", - "server/src/email/core/providers/dummy.ts" - ], - "e93a7a02f50c8466f3e8e89255b98bebde598b25f9969ec117b16f07691575ae" - ], [ [ "file", @@ -277,14 +298,14 @@ "file", "server/src/email/core/types.ts" ], - "c343f0d87b65d7563816159a88f410b65d78d897822c0bbcd723ca7752e00a20" + "0d7c19707f4e7c498a458015b1065b3f84c31e53ba73807707a05b7293473eb2" ], [ [ "file", "server/src/email/index.ts" ], - "c4864d5c83b96a61b1ddfaac7b52c0898f5cff04320c166c8f658b017952ee05" + "4443efa3da16d8d950bde84acd2ed6d9bc3a5d211ce3138248e3b5bad5079978" ], [ [ @@ -405,6 +426,13 @@ ], "64eeed927f46f6d6eba143023f25fb9ac4cd81d6b68c9a7067306ad28a3eda92" ], + [ + [ + "file", + "server/src/polyfill.ts" + ], + "66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf" + ], [ [ "file", @@ -431,14 +459,21 @@ "file", "server/src/routes/auth/index.js" ], - "47fb3317c5707e0d7646bb1c0f6fa8d9c5c0f980ca2d643d226e63b49766cea3" + "9761e5af295928520748246d3be0ba4113655dbef3afb5c9123894627f3bee1a" + ], + [ + [ + "file", + "server/src/routes/auth/logout.ts" + ], + "1324b110888548ab535d78a4c7614c996152e948144a873d0262fa0aeb7ab4dc" ], [ [ "file", "server/src/routes/auth/me.js" ], - "705f77d8970a8367981c9a89601c6d5b12e998c23970ae1735b376dd0826ef10" + "9a9cb533bb94af63caf448f73a0d0fef8902c8f8d1af411bed2570a32da2fab9" ], [ [ @@ -487,14 +522,14 @@ "file", "server/src/server.ts" ], - "93c05fac0fb2e30eeda90dbb374bfa5c7fcb860b4605da8ae2c6b6f913f95963" + "7963a3e625deb86593258b01dd87c94cf4e2103dbda3a6ee82e672911dee09bc" ], [ [ "file", "server/src/types/index.ts" ], - "0044d2263b936cbc20a278afa4aa61cd9d85821325f490b3cee8bbfb068ed836" + "f7621082fc7d8467a0967eb0bd82ff7956052b766e9e82d50584b8de88e0d28a" ], [ [ @@ -515,7 +550,7 @@ "file", "server/src/utils.ts" ], - "f8834df362946064f32ef6a145769f83d10da712ad3daa226243fc590f89618f" + "7d29cb34de86e6a0689655e4165aed9fe5b0f82f54e4194f002f7d5823c7cb18" ], [ [ @@ -627,21 +662,21 @@ "file", "web-app/src/actions/index.ts" ], - "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092" + "607c3311861456ae47c246a950c8e29593f9837a9f5c48923d99cd7fac1ce0bb" ], [ [ "file", "web-app/src/api.ts" ], - "93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf" + "850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104" ], [ [ "file", "web-app/src/api/events.ts" ], - "7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044" + "91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a" ], [ [ @@ -718,21 +753,21 @@ "file", "web-app/src/auth/helpers/user.ts" ], - "e6bc091d8f8520db542f959846ecf528e8a070c5ce989151d00d2f45da4a58a6" + "e57cecd0a50b1515d6da8fcfb45f1e98ebefa4f58fe59d792100ffd40980fee9" ], [ [ "file", "web-app/src/auth/logout.ts" ], - "6717411aa38e54aa74a5034628510ee3ca0f2ea9d1692644ee4886c74d8657af" + "e04607e4676af958f580152432e873452a33a4276ecdb1a4c368efe2e3958a6c" ], [ [ "file", "web-app/src/auth/pages/OAuthCodeExchange.jsx" ], - "7dbcc288201aafbb50b5f5319a28283546c81d006fe61c2a8a3c5f55c6833fb2" + "1536a0abfd92944dca315e344758ba50f44a18e34c18a8c1c91046b1e4cc2a5e" ], [ [ @@ -746,7 +781,7 @@ "file", "web-app/src/auth/types.ts" ], - "5ce8d0493c362093b0b2fc7b9df78a86688d3f40264ea8f29530f1d8fa67c4c6" + "26b1d53d6ea48d56421ee7050486eb9086367195534f21ca3371cd0685307d7c" ], [ [ @@ -760,7 +795,7 @@ "file", "web-app/src/auth/user.ts" ], - "7113c286081f5597b822f5e576735d321cce38fcbd1a25db0d90e1163570068f" + "08cd2cf7dbb5aa5371efcfc1b16651c0c59d03d4ea36cd9f69bdaa9edc5ca68e" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma index 69c01a4535..ea120405ad 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma @@ -38,6 +38,7 @@ model Auth { userId Int? @unique user User? @relation(fields: [userId], references: [id], onDelete: Cascade) identities AuthIdentity[] + sessions Session[] } model AuthIdentity { @@ -49,3 +50,11 @@ model AuthIdentity { @@id([providerName, providerUserId]) } +model Session { + id String @id @unique + expiresAt DateTime + userId String + auth Auth @relation(references: [id], fields: [userId], onDelete: Cascade) + @@index([userId]) + +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum index b340294f3e..a654c68c7b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum @@ -1 +1 @@ -3e6bfc3dadfc9399a3fae3ea77cadb1aa3259efa0b8362ed77d6d5ee5013d393 \ No newline at end of file +5a41561cd6f853da2f4b9f64267f5d4e3b2865f94dfa92d9143acf2c1485519c \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json index 82f79b0197..24e5342120 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json @@ -1 +1 @@ -{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.16.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.16.2"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.0.0"},{"name":"@tsconfig/node18","version":"latest"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.16.2"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}} \ No newline at end of file +{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.16.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"lucia","version":"^3.0.0-beta.14"},{"name":"@lucia-auth/adapter-prisma","version":"^4.0.0-beta.9"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.16.2"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.0.0"},{"name":"@tsconfig/node18","version":"latest"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.16.2"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json index 0aa97eb91a..d17c69c2d5 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@lucia-auth/adapter-prisma": "^4.0.0-beta.9", "@prisma/client": "4.16.2", "@sendgrid/mail": "^7.7.0", "cookie-parser": "~1.4.6", @@ -9,6 +10,7 @@ "helmet": "^6.0.0", "jsonwebtoken": "^8.5.1", "lodash.merge": "^4.6.2", + "lucia": "^3.0.0-beta.14", "morgan": "~1.10.0", "passport": "0.6.0", "passport-google-oauth20": "2.0.0", diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts index 23017df130..fc52fa392d 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts @@ -82,18 +82,18 @@ type Context = Expand<{ type ContextWithUser = Expand & { user?: SanitizedUser }> -// TODO: This type must match the logic in core/auth.js (if we remove the +// TODO: This type must match the logic in auth/session.js (if we remove the // password field from the object there, we must do the same here). Ideally, // these two things would live in the same place: // https://github.com/wasp-lang/wasp/issues/965 -export type DeserializedAuthEntity = Expand & { +export type DeserializedAuthIdentity = Expand & { providerData: Omit | Omit | OAuthProviderData }> export type SanitizedUser = User & { auth: Auth & { - identities: DeserializedAuthEntity[] + identities: DeserializedAuthIdentity[] } | null } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/jwt.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/jwt.ts new file mode 100644 index 0000000000..5d2f4ae6fa --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/jwt.ts @@ -0,0 +1,12 @@ +import jwt from 'jsonwebtoken' +import util from 'util' + +import config from '../config.js' + +const jwtSign = util.promisify(jwt.sign) +const jwtVerify = util.promisify(jwt.verify) + +const JWT_SECRET = config.auth.jwtSecret + +export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) +export const verify = (token) => jwtVerify(token, JWT_SECRET) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/lucia.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/lucia.ts new file mode 100644 index 0000000000..bb9135c243 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/lucia.ts @@ -0,0 +1,55 @@ +import { Lucia } from "lucia"; +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; +import prisma from '../dbClient.js' +import config from '../config.js' +import { type User } from "../entities/index.js" + +const prismaAdapter = new PrismaAdapter( + // Using `as any` here since Lucia's model types are not compatible with Prisma 4 + // model types. This is a temporary workaround until we migrate to Prisma 5. + // This **works** in runtime, but Typescript complains about it. + prisma.session as any, + prisma.auth as any +); + +/** + * We are using Lucia for session management. + * + * Some details: + * 1. We are using the Prisma adapter for Lucia. + * 2. We are not using cookies for session management. Instead, we are using + * the Authorization header to send the session token. + * 3. Our `Session` entity is connected to the `Auth` entity. + * 4. We are exposing the `userId` field from the `Auth` entity to + * make fetching the User easier. + */ +export const auth = new Lucia<{}, { + userId: User['id'] +}>(prismaAdapter, { + // Since we are not using cookies, we don't need to set any cookie options. + // But in the future, if we decide to use cookies, we can set them here. + + // sessionCookie: { + // name: "session", + // expires: true, + // attributes: { + // secure: !config.isDevelopment, + // sameSite: "lax", + // }, + // }, + getUserAttributes({ userId }) { + return { + userId, + }; + }, +}); + +declare module "lucia" { + interface Register { + Lucia: typeof auth; + DatabaseSessionAttributes: {}; + DatabaseUserAttributes: { + userId: User['id'] + }; + } +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/password.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/password.ts new file mode 100644 index 0000000000..a359892b5e --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/password.ts @@ -0,0 +1,15 @@ +import SecurePassword from 'secure-password' + +const SP = new SecurePassword() + +export const hashPassword = async (password: string): Promise => { + const hashedPwdBuffer = await SP.hash(Buffer.from(password)) + return hashedPwdBuffer.toString("base64") +} + +export const verifyPassword = async (hashedPassword: string, password: string): Promise => { + const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) + if (result !== SecurePassword.VALID) { + throw new Error('Invalid password.') + } +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts index 0525771050..bb1c95b470 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts @@ -5,7 +5,7 @@ import { makeOAuthInit } from "../oauth/init.js"; import type { ProviderConfig } from "../types.js"; import type { OAuthConfig } from "../oauth/types.js"; -const _waspGetUserFieldsFn = undefined +const _waspUserSignupFields = undefined const _waspUserDefinedConfigFn = undefined const _waspOAuthConfig: OAuthConfig = { @@ -19,7 +19,7 @@ const _waspConfig: ProviderConfig = { displayName: "Google", init: makeOAuthInit({ npmPackage: 'passport-google-oauth20', - getUserFieldsFn: _waspGetUserFieldsFn, + userSignupFields: _waspUserSignupFields, userDefinedConfigFn: _waspUserDefinedConfigFn, oAuthConfig: _waspOAuthConfig, }), diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts index a64162b8dd..3b34263c34 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts @@ -11,20 +11,22 @@ import { authConfig, contextWithUserEntity, createUser, - findAuthWithUserBy, - createAuthToken, rethrowPossibleAuthError, sanitizeAndSerializeProviderData, + validateAndGetUserFields, } from "../../utils.js" -import { type User } from "../../../entities/index.js" -import type { ProviderConfig, RequestWithWasp } from "../types.js" -import type { GetUserFieldsFn } from "./types.js" +import { createSession } from "../../session.js" +import { type Auth } from "../../../entities/index.js" +import type { ProviderConfig, RequestWithWasp, UserSignupFields } from "../types.js" import { handleRejection } from "../../../utils.js" // For oauth providers, we have an endpoint /login to get the auth URL, // and the /callback endpoint which is used to get the actual access_token and the user info. -export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn?: GetUserFieldsFn }) { - const { passportStrategyName, getUserFieldsFn } = initData; +export function createRouter(provider: ProviderConfig, initData: { + passportStrategyName: string, + userSignupFields?: UserSignupFields, +}) { + const { passportStrategyName, userSignupFields } = initData; const router = Router(); @@ -52,9 +54,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat const providerId = createProviderId(provider.id, providerProfile.id); try { - const userId = await getUserIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn) - const token = await createAuthToken(userId) - res.json({ token }) + const authId = await getAuthIdFromProviderDetails(providerId, providerProfile, userSignupFields) + const session = await createSession(authId) + return res.json({ + sessionId: session.id, + }) } catch (e) { rethrowPossibleAuthError(e) } @@ -66,11 +70,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat // We need a user id to create the auth token, so we either find an existing user // or create a new one if none exists for this provider. -async function getUserIdFromProviderDetails( +async function getAuthIdFromProviderDetails( providerId: ProviderId, providerProfile: any, - getUserFieldsFn?: GetUserFieldsFn, -): Promise { + userSignupFields?: UserSignupFields, +): Promise { const existingAuthIdentity = await prisma.authIdentity.findUnique({ where: { providerName_providerUserId: providerId, @@ -85,11 +89,12 @@ async function getUserIdFromProviderDetails( }) if (existingAuthIdentity) { - return existingAuthIdentity.auth.user.id + return existingAuthIdentity.auth.id } else { - const userFields = getUserFieldsFn - ? await getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) - : {}; + const userFields = await validateAndGetUserFields( + { profile: providerProfile }, + userSignupFields, + ); // For now, we don't have any extra data for the oauth providers, so we just pass an empty object. const providerData = await sanitizeAndSerializeProviderData({}) @@ -97,9 +102,11 @@ async function getUserIdFromProviderDetails( const user = await createUser( providerId, providerData, - userFields, + // Using any here because we want to avoid TypeScript errors and + // rely on Prisma to validate the data. + userFields as any, ) - return user.id + return user.auth.id } } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts index ac5a56dafe..1462f3a2f7 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts @@ -2,10 +2,10 @@ import passport from "passport"; import waspServerConfig from '../../../config.js'; -import type { InitData, ProviderConfig, RequestWithWasp } from "../types.js"; -import type { OAuthConfig, GetUserFieldsFn, UserDefinedConfigFn } from "./types.js"; +import type { InitData, ProviderConfig, RequestWithWasp, UserSignupFields } from "../types.js"; +import type { OAuthConfig, UserDefinedConfigFn } from "./types.js"; -export function makeOAuthInit({ userDefinedConfigFn, getUserFieldsFn, npmPackage, oAuthConfig }: OAuthImports) { +export function makeOAuthInit({ userDefinedConfigFn, userSignupFields, npmPackage, oAuthConfig }: OAuthImports) { return async function init(provider: ProviderConfig): Promise { const userDefinedConfig = userDefinedConfigFn ? userDefinedConfigFn() @@ -35,7 +35,7 @@ export function makeOAuthInit({ userDefinedConfigFn, getUserFieldsFn, npmPackage return { passportStrategyName, - getUserFieldsFn, + userSignupFields, }; } } @@ -72,5 +72,5 @@ export type OAuthImports = { npmPackage: string; userDefinedConfigFn?: UserDefinedConfigFn; oAuthConfig: OAuthConfig; - getUserFieldsFn?: GetUserFieldsFn; + userSignupFields?: UserSignupFields; }; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts index ca1e7a3f50..3b673a4858 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts @@ -11,8 +11,3 @@ export type OAuthConfig = { export type UserFieldsFromOAuthSignup = Prisma.UserCreateInput export type UserDefinedConfigFn = () => { [key: string]: any } - -export type GetUserFieldsFn = ( - context: typeof contextWithUserEntity, - args: { profile: { [key: string]: any } }, -) => Promise diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts index 9defb94486..f19198ca86 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts @@ -23,16 +23,18 @@ export type InitData = { export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } -export type PossibleAdditionalSignupFields = Expand> +export type PossibleUserFields = Expand> -export function defineAdditionalSignupFields(config: { - [key in keyof PossibleAdditionalSignupFields]: FieldGetter< - PossibleAdditionalSignupFields[key] +export type UserSignupFields = { + [key in keyof PossibleUserFields]: FieldGetter< + PossibleUserFields[key] > -}) { - return config } type FieldGetter = ( data: { [key: string]: unknown } ) => Promise | T | undefined + +export function defineUserSignupFields(fields: UserSignupFields) { + return fields +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/session.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/session.ts new file mode 100644 index 0000000000..ed9154120b --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/session.ts @@ -0,0 +1,107 @@ +import { Request as ExpressRequest } from "express"; + +import { type User } from "../entities/index.js" +import { type SanitizedUser } from '../server/_types/index.js' + +import { auth } from "./lucia.js"; +import type { Session } from "lucia"; +import { + throwInvalidCredentialsError, + deserializeAndSanitizeProviderData, +} from "./utils.js"; + +import prisma from '../server/dbClient.js' + +// Creates a new session for the `authId` in the database +export async function createSession(authId: string): Promise { + return auth.createSession(authId, {}); +} + +export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const authorizationHeader = req.headers["authorization"]; + + if (typeof authorizationHeader !== "string") { + return { + user: null, + session: null, + }; + } + + const sessionId = auth.readBearerToken(authorizationHeader); + if (!sessionId) { + return { + user: null, + session: null, + }; + } + + return getSessionAndUserFromSessionId(sessionId); +} + +export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{ + user: SanitizedUser | null, + session: Session | null, +}> { + const { session, user: authEntity } = await auth.validateSession(sessionId); + + if (!session || !authEntity) { + return { + user: null, + session: null, + }; + } + + return { + session, + user: await getUser(authEntity.userId) + } +} + +async function getUser(userId: User['id']): Promise { + const user = await prisma.user + .findUnique({ + where: { id: userId }, + include: { + auth: { + include: { + identities: true + } + } + } + }) + + if (!user) { + throwInvalidCredentialsError() + } + + // TODO: This logic must match the type in _types/index.ts (if we remove the + // password field from the object here, we must to do the same there). + // Ideally, these two things would live in the same place: + // https://github.com/wasp-lang/wasp/issues/965 + const deserializedIdentities = user.auth.identities.map((identity) => { + const deserializedProviderData = deserializeAndSanitizeProviderData( + identity.providerData, + { + shouldRemovePasswordField: true, + } + ) + return { + ...identity, + providerData: deserializedProviderData, + } + }) + return { + ...user, + auth: { + ...user.auth, + identities: deserializedIdentities, + }, + } +} + +export function invalidateSession(sessionId: string): Promise { + return auth.invalidateSession(sessionId); +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/user.ts index a5d987fc4e..410e92058b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/user.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/user.ts @@ -2,7 +2,7 @@ // We have them duplicated in this file and in data/Generator/templates/react-app/src/auth/user.ts // If you are changing the logic here, make sure to change it there as well. -import type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../_types/index' +import type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../_types/index' export function getEmail(user: User): string | null { return findUserIdentity(user, "email")?.providerUserId ?? null; @@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null { return user.auth.identities[0].providerUserId ?? null; } -export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined { return user.auth.identities.find( (identity) => identity.providerName === providerName ); diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts index ba1ad7074c..dfde8ac98b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts @@ -1,4 +1,5 @@ -import { hashPassword, sign, verify } from '../core/auth.js' +import { hashPassword } from './password.js' +import { verify } from './jwt.js' import AuthError from '../core/AuthError.js' import HttpError from '../core/HttpError.js' import prisma from '../dbClient.js' @@ -12,9 +13,7 @@ import { Prisma } from '@prisma/client'; import { throwValidationError } from './validation.js' - -import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js' -const _waspAdditionalSignupFieldsConfig = {} as ReturnType +import { type UserSignupFields, type PossibleUserFields } from './providers/types.js' export type EmailProviderData = { hashedPassword: string; @@ -127,8 +126,10 @@ export async function findAuthWithUserBy( export async function createUser( providerId: ProviderId, serializedProviderData?: string, - userFields?: PossibleAdditionalSignupFields, -): Promise { + userFields?: PossibleUserFields, +): Promise { return prisma.user.create({ data: { // Using any here to prevent type errors when userFields are not @@ -145,7 +146,12 @@ export async function createUser( }, } }, - } + }, + // We need to include the Auth entity here because we need `authId` + // to be able to create a session. + include: { + auth: true, + }, }) } @@ -155,12 +161,6 @@ export async function deleteUserByAuthId(authId: string): Promise<{ count: numbe } } }) } -export async function createAuthToken( - userId: User['id'] -): Promise { - return sign(userId); -} - export async function verifyToken(token: string): Promise { return verify(token); } @@ -224,15 +224,23 @@ export function rethrowPossibleAuthError(e: unknown): void { throw e } -export async function validateAndGetAdditionalFields(data: { - [key: string]: unknown -}): Promise> { +export async function validateAndGetUserFields( + data: { + [key: string]: unknown + }, + userSignupFields?: UserSignupFields, +): Promise> { const { password: _password, ...sanitizedData } = data; const result: Record = {}; - for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) { + + if (!userSignupFields) { + return result; + } + + for (const [field, getFieldValue] of Object.entries(userSignupFields)) { try { const value = await getFieldValue(sanitizedData) result[field] = value @@ -288,3 +296,7 @@ function providerDataHasPasswordField( ): providerData is { hashedPassword: string } { return 'hashedPassword' in providerData; } + +export function throwInvalidCredentialsError(message?: string): void { + throw new HttpError(401, 'Invalid credentials', { message }) +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js index 9c8c03ce91..1d01dd6858 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js @@ -1,23 +1,22 @@ -import jwt from 'jsonwebtoken' -import SecurePassword from 'secure-password' -import util from 'util' import { randomInt } from 'node:crypto' import prisma from '../dbClient.js' import { handleRejection } from '../utils.js' -import HttpError from '../core/HttpError.js' -import config from '../config.js' -import { deserializeAndSanitizeProviderData } from '../auth/utils.js' - -const jwtSign = util.promisify(jwt.sign) -const jwtVerify = util.promisify(jwt.verify) - -const JWT_SECRET = config.auth.jwtSecret - -export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) -export const sign = (id, options) => signData({ id }, options) -export const verify = (token) => jwtVerify(token, JWT_SECRET) - +import { getSessionAndUserFromBearerToken } from '../auth/session.js' +import { throwInvalidCredentialsError } from '../auth/utils.js' + +/** + * Auth middleware + * + * If the request includes an `Authorization` header it will try to authenticate the request, + * otherwise it will let the request through. + * + * - If authentication succeeds it sets `req.sessionId` and `req.user` + * - `req.user` is the user that made the request and it's used in + * all Wasp features that need to know the user that made the request. + * - `req.sessionId` is the ID of the session that authenticated the request. + * - If the request is not authenticated, it throws an error. + */ const auth = handleRejection(async (req, res, next) => { const authHeader = req.get('Authorization') if (!authHeader) { @@ -27,119 +26,16 @@ const auth = handleRejection(async (req, res, next) => { return next() } - if (authHeader.startsWith('Bearer ')) { - const token = authHeader.substring(7, authHeader.length) - req.user = await getUserFromToken(token) - } else { - throwInvalidCredentialsError() - } + const { session, user } = await getSessionAndUserFromBearerToken(req); - next() -}) - -export async function getUserFromToken(token) { - let userIdFromToken - try { - userIdFromToken = (await verify(token)).id - } catch (error) { - if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) { - throwInvalidCredentialsError() - } else { - throw error - } - } - - const user = await prisma.user - .findUnique({ - where: { id: userIdFromToken }, - include: { - auth: { - include: { - identities: true - } - } - } - }) - if (!user) { + if (!session || !user) { throwInvalidCredentialsError() } - // TODO: This logic must match the type in types/index.ts (if we remove the - // password field from the object here, we must to do the same there). - // Ideally, these two things would live in the same place: - // https://github.com/wasp-lang/wasp/issues/965 - let sanitizedUser = { ...user } - sanitizedUser.auth.identities = sanitizedUser.auth.identities.map(identity => { - identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true }) - return identity - }); - return sanitizedUser -} - -const SP = new SecurePassword() - -export const hashPassword = async (password) => { - const hashedPwdBuffer = await SP.hash(Buffer.from(password)) - return hashedPwdBuffer.toString("base64") -} - -export const verifyPassword = async (hashedPassword, password) => { - const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) - if (result !== SecurePassword.VALID) { - throw new Error('Invalid password.') - } -} + req.sessionId = session.id + req.user = user -// Generates an unused username that looks similar to "quick-purple-sheep-91231". -// It generates several options and ensures it picks one that is not currently in use. -export function generateAvailableDictionaryUsername() { - const adjectives = ['fuzzy', 'tall', 'short', 'nice', 'happy', 'quick', 'slow', 'good', 'new', 'old', 'first', 'last', 'old', 'young'] - const colors = ['red', 'green', 'blue', 'white', 'black', 'brown', 'purple', 'orange', 'yellow'] - const nouns = ['wasp', 'cat', 'dog', 'lion', 'rabbit', 'duck', 'pig', 'bee', 'goat', 'crab', 'fish', 'chicken', 'horse', 'llama', 'camel', 'sheep'] - - const potentialUsernames = [] - for (let i = 0; i < 10; i++) { - const potentialUsername = `${adjectives[randomInt(adjectives.length)]}-${colors[randomInt(colors.length)]}-${nouns[randomInt(nouns.length)]}-${randomInt(100_000)}` - potentialUsernames.push(potentialUsername) - } - - return findAvailableUsername(potentialUsernames) -} - -// Generates an unused username based on an array of username segments and a separator. -// It generates several options and ensures it picks one that is not currently in use. -export function generateAvailableUsername(usernameSegments, config) { - const separator = config?.separator || '-' - const baseUsername = usernameSegments.join(separator) - - const potentialUsernames = [] - for (let i = 0; i < 10; i++) { - const potentialUsername = `${baseUsername}${separator}${randomInt(100_000)}` - potentialUsernames.push(potentialUsername) - } - - return findAvailableUsername(potentialUsernames) -} - -// Checks the database for an unused username from an array provided and returns first. -async function findAvailableUsername(potentialUsernames) { - const users = await prisma.user.findMany({ - where: { - username: { in: potentialUsernames }, - } - }) - const takenUsernames = users.map(user => user.username) - const availableUsernames = potentialUsernames.filter(username => !takenUsernames.includes(username)) - - if (availableUsernames.length === 0) { - throw new Error('Unable to generate a unique username. Please contact Wasp.') - } - - return availableUsernames[0] -} - -export function throwInvalidCredentialsError(message) { - throw new HttpError(401, 'Invalid credentials', { message }) -} + next() +}) export default auth diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts index e2de0a4a3a..f862fe505b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts @@ -10,7 +10,7 @@ import { Payload } from "../_types/serialization.js"; import type { Task, } from "../entities"; -import { throwInvalidCredentialsError } from "../core/auth.js"; +import { throwInvalidCredentialsError } from '../auth/utils.js' type _WaspEntityTagged = _Task type _WaspEntity = Task diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts deleted file mode 100644 index b4b3ef0450..0000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/providers/dummy.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { EmailSender } from "../types.js"; -import { getDefaultFromField } from "../helpers.js"; - -export function initDummyEmailSender(): EmailSender { - const defaultFromField = getDefaultFromField(); - return { - send: async (email) => { - const fromField = email.from || defaultFromField; - console.log('Test email (not sent):', { - from: { - email: fromField.email, - name: fromField.name, - }, - to: email.to, - subject: email.subject, - text: email.text, - html: email.html, - }); - return { - success: true, - }; - } - } -} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts index 9a2440038a..f5274ac498 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/core/types.ts @@ -1,4 +1,4 @@ -export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider; +export type EmailProvider = SMTPEmailProvider | SendGridProvider | MailgunEmailProvider | DummyEmailProvider; export type SMTPEmailProvider = { type: "smtp"; @@ -19,6 +19,10 @@ export type MailgunEmailProvider = { domain: string; }; +export type DummyEmailProvider = { + type: "dummy"; +} + export type EmailSender = { send: (email: Email) => Promise; }; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts index 47f4e8e649..4deb2c8c68 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/email/index.ts @@ -1,16 +1,8 @@ import { initEmailSender } from "./core/index.js"; -import waspServerConfig from '../config.js'; -import { initDummyEmailSender } from "./core/providers/dummy.js"; - const emailProvider = { type: "sendgrid", apiKey: process.env.SENDGRID_API_KEY, } as const; -const areEmailsSentInDevelopment = process.env.SEND_EMAILS_IN_DEVELOPMENT === "true"; -const isDummyEmailSenderUsed = waspServerConfig.isDevelopment && !areEmailsSentInDevelopment; - -export const emailSender = isDummyEmailSenderUsed - ? initDummyEmailSender() - : initEmailSender(emailProvider); \ No newline at end of file +export const emailSender = initEmailSender(emailProvider); diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/polyfill.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/polyfill.ts new file mode 100644 index 0000000000..a59302451a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/polyfill.ts @@ -0,0 +1,7 @@ +// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it +// for random number generation. + +import { webcrypto } from "node:crypto"; + +// @ts-ignore +globalThis.crypto = webcrypto as Crypto; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js index 0fe478cd80..b8041270b2 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/index.js @@ -2,12 +2,14 @@ import express from 'express' import auth from '../../core/auth.js' import me from './me.js' +import logout from './logout.js' import providersRouter from '../../auth/providers/index.js' const router = express.Router() router.get('/me', auth, me) +router.post('/logout', auth, logout) router.use('/', providersRouter) export default router diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/logout.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/logout.ts new file mode 100644 index 0000000000..10a24a7bc5 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/logout.ts @@ -0,0 +1,12 @@ +import { handleRejection } from '../../utils.js' +import { throwInvalidCredentialsError } from '../../auth/utils.js' +import { invalidateSession } from '../../auth/session.js' + +export default handleRejection(async (req, res) => { + if (req.sessionId) { + await invalidateSession(req.sessionId) + return res.json({ success: true }) + } else { + throwInvalidCredentialsError() + } +}) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js index b1f2cae496..e84aff221a 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/routes/auth/me.js @@ -1,6 +1,6 @@ import { serialize as superjsonSerialize } from 'superjson' import { handleRejection } from '../../utils.js' -import { throwInvalidCredentialsError } from '../../core/auth.js' +import { throwInvalidCredentialsError } from '../../auth/utils.js' export default handleRejection(async (req, res) => { if (req.user) { diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/server.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/server.ts index 48b5caa83d..b34339f7c4 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/server.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/server.ts @@ -10,6 +10,8 @@ import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js' import './jobs/core/allJobs.js' +import './polyfill.js' + const startServer = async () => { await startPgBoss() diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/types/index.ts index a7f36194fb..b30c92f5ec 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/types/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/types/index.ts @@ -12,5 +12,3 @@ export type ServerSetupFnContext = { export type { Application } from 'express' export type { Server } from 'http' -export type { GetUserFieldsFn } from '../auth/providers/oauth/types'; - diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.ts index a930149d08..b0744f3129 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.ts @@ -8,7 +8,8 @@ import { fileURLToPath } from 'url' import { type SanitizedUser } from './_types/index.js' type RequestWithExtraFields = Request & { - user?: SanitizedUser + user?: SanitizedUser; + sessionId?: string; } /** diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/index.ts index 5e4dfedd12..7fb2de2f9e 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/actions/index.ts @@ -42,7 +42,7 @@ export type UpdateQuery = (item: ActionInput, oldData: /** * A public query specifier used for addressing Wasp queries. See our docs for details: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates */ export type QuerySpecifier = [Query, ...any[]] @@ -116,7 +116,7 @@ type InternalAction = Action & { * * @param publicOptimisticUpdateDefinition An optimistic update definition * object that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns An internally-used optimistic update definition object. */ function translateToInternalDefinition( @@ -260,7 +260,7 @@ function getOptimisticUpdateDefinitionForSpecificItem( * Translates a Wasp query specifier to a query cache key used by React Query. * * @param querySpecifier A query specifier that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns A cache key React Query internally uses for addressing queries. */ function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier): QueryKey { diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.ts index d7532f65c6..17e36c1248 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api.ts @@ -8,59 +8,60 @@ const api = axios.create({ baseURL: config.apiUrl, }) -const WASP_APP_AUTH_TOKEN_NAME = 'authToken' +const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId' -let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined +let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined -export function setAuthToken(token: string): void { - authToken = token - storage.set(WASP_APP_AUTH_TOKEN_NAME, token) - apiEventsEmitter.emit('authToken.set') +export function setSessionId(sessionId: string): void { + waspAppAuthSessionId = sessionId + storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId) + apiEventsEmitter.emit('sessionId.set') } -export function getAuthToken(): string | undefined { - return authToken +export function getSessionId(): string | undefined { + return waspAppAuthSessionId } -export function clearAuthToken(): void { - authToken = undefined - storage.remove(WASP_APP_AUTH_TOKEN_NAME) - apiEventsEmitter.emit('authToken.clear') +export function clearSessionId(): void { + waspAppAuthSessionId = undefined + storage.remove(WASP_APP_AUTH_SESSION_ID_NAME) + apiEventsEmitter.emit('sessionId.clear') } export function removeLocalUserData(): void { - authToken = undefined + waspAppAuthSessionId = undefined storage.clear() - apiEventsEmitter.emit('authToken.clear') + apiEventsEmitter.emit('sessionId.clear') } api.interceptors.request.use((request) => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` + const sessionId = getSessionId() + if (sessionId) { + request.headers['Authorization'] = `Bearer ${sessionId}` } return request }) api.interceptors.response.use(undefined, (error) => { if (error.response?.status === 401) { - clearAuthToken() + clearSessionId() } return Promise.reject(error) }) // This handler will run on other tabs (not the active one calling API functions), -// and will ensure they know about auth token changes. +// and will ensure they know about auth session ID changes. // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event // "Note: This won't work on the same page that is making the changes — it is really a way // for other pages on the domain using the storage to sync any changes that are made." window.addEventListener('storage', (event) => { - if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) { + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { if (!!event.newValue) { - authToken = event.newValue - apiEventsEmitter.emit('authToken.set') + waspAppAuthSessionId = event.newValue + apiEventsEmitter.emit('sessionId.set') } else { - authToken = undefined - apiEventsEmitter.emit('authToken.clear') + waspAppAuthSessionId = undefined + apiEventsEmitter.emit('sessionId.clear') } } }) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api/events.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api/events.ts index 9a59b366d3..a72e48dda8 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api/events.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/api/events.ts @@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt'; type ApiEvents = { // key: Event name // type: Event payload type - 'authToken.set': void; - 'authToken.clear': void; + 'sessionId.set': void; + 'sessionId.clear': void; }; -// Used to allow API clients to register for auth token change events. +// Used to allow API clients to register for auth session ID change events. export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts index 1c6fc500f4..a6b06299ce 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts @@ -1,8 +1,8 @@ -import { setAuthToken } from '../../api' +import { setSessionId } from '../../api' import { invalidateAndRemoveQueries } from '../../operations/resources' -export async function initSession(token: string): Promise { - setAuthToken(token) +export async function initSession(sessionId: string): Promise { + setSessionId(sessionId) // We need to invalidate queries after login in order to get the correct user // data in the React components (using `useAuth`). // Redirects after login won't work properly without this. diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/logout.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/logout.ts index 44b9e05c33..715f99a49b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/logout.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/logout.ts @@ -1,9 +1,17 @@ -import { removeLocalUserData } from '../api' +import api, { removeLocalUserData } from '../api' import { invalidateAndRemoveQueries } from '../operations/resources' export default async function logout(): Promise { - removeLocalUserData() - // TODO(filip): We are currently invalidating and removing all the queries, but - // we should remove only the non-public, user-dependent ones. - await invalidateAndRemoveQueries() + try { + await api.post('/auth/logout') + } finally { + // Even if the logout request fails, we still want to remove the local user data + // in case the logout failed because of a network error and the user walked away + // from the computer. + removeLocalUserData() + + // TODO(filip): We are currently invalidating and removing all the queries, but + // we should remove only the non-public, user-dependent ones. + await invalidateAndRemoveQueries() + } } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx index 83ffc0ee93..490f76d0ad 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx @@ -28,7 +28,7 @@ export default function OAuthCodeExchange({ pathToApiServerRouteHandlingOauthRed // This helps us reuse one component for various methods (e.g., Google, Facebook, etc.). const apiServerUrlHandlingOauthRedirect = constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect) - exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) + exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect) return () => { firstRender.current = false } @@ -46,22 +46,22 @@ function constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRed return `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}` } -async function exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) { - const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect) +async function exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect) { + const sessionId = await exchangeCodeForSessionId(apiServerUrlHandlingOauthRedirect) - if (token !== null) { - await initSession(token) + if (sessionId !== null) { + await initSession(sessionId) history.push('/') } else { - console.error('Error obtaining JWT token') + console.error('Error obtaining session ID') history.push('/login') } } -async function exchangeCodeForJwt(url) { +async function exchangeCodeForSessionId(url) { try { const response = await api.get(url) - return response?.data?.token || null + return response?.data?.sessionId || null } catch (e) { console.error(e) return null diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts index 4405410cc7..637a2e13d4 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts @@ -1,2 +1,2 @@ // todo(filip): turn into a proper import/path -export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../../../server/src/_types/' +export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../../../server/src/_types/' diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/user.ts index 5799c71ea7..aa0da24824 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/user.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/user.ts @@ -2,7 +2,7 @@ // We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts // If you are changing the logic here, make sure to change it there as well. -import type { User, ProviderName, DeserializedAuthEntity } from './types' +import type { User, ProviderName, DeserializedAuthIdentity } from './types' export function getEmail(user: User): string | null { return findUserIdentity(user, "email")?.providerUserId ?? null; @@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null { return user.auth.identities[0].providerUserId ?? null; } -export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined { return user.auth.identities.find( (identity) => identity.providerName === providerName ); diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest index 356ddfd991..91b05f149a 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest @@ -31,6 +31,7 @@ waspJob/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.ts waspJob/.wasp/out/server/src/middleware/globalMiddleware.ts waspJob/.wasp/out/server/src/middleware/index.ts waspJob/.wasp/out/server/src/middleware/operations.ts +waspJob/.wasp/out/server/src/polyfill.ts waspJob/.wasp/out/server/src/queries/types.ts waspJob/.wasp/out/server/src/routes/index.js waspJob/.wasp/out/server/src/routes/operations/index.js diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index 753356375d..f9f7edfd5e 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -216,6 +216,13 @@ ], "864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07" ], + [ + [ + "file", + "server/src/polyfill.ts" + ], + "66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf" + ], [ [ "file", @@ -242,14 +249,14 @@ "file", "server/src/server.ts" ], - "e28a2e72f8a0cedbfba8c6acfbc36b9ae35db9005aa26484d88ef7e7688efa5b" + "d26cf3913b82c3525fe2214e0d94606e36ccb306ca9b036979e003f1e1d44f4b" ], [ [ "file", "server/src/types/index.ts" ], - "1958cfc3e3b5f59490168797e4b8dcdc38f32346e734f90df3fb6baa264b36b5" + "f7621082fc7d8467a0967eb0bd82ff7956052b766e9e82d50584b8de88e0d28a" ], [ [ @@ -375,21 +382,21 @@ "file", "web-app/src/actions/index.ts" ], - "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092" + "607c3311861456ae47c246a950c8e29593f9837a9f5c48923d99cd7fac1ce0bb" ], [ [ "file", "web-app/src/api.ts" ], - "93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf" + "850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104" ], [ [ "file", "web-app/src/api/events.ts" ], - "7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044" + "91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/polyfill.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/polyfill.ts new file mode 100644 index 0000000000..a59302451a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/polyfill.ts @@ -0,0 +1,7 @@ +// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it +// for random number generation. + +import { webcrypto } from "node:crypto"; + +// @ts-ignore +globalThis.crypto = webcrypto as Crypto; diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/server.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/server.ts index 8c59523a89..01edef6b1b 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/server.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/server.ts @@ -8,6 +8,8 @@ import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js' import './jobs/core/allJobs.js' +import './polyfill.js' + const startServer = async () => { await startPgBoss() diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/types/index.ts index 9540b5dddc..b30c92f5ec 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/types/index.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/types/index.ts @@ -12,4 +12,3 @@ export type ServerSetupFnContext = { export type { Application } from 'express' export type { Server } from 'http' - diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.ts index 5e4dfedd12..7fb2de2f9e 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/actions/index.ts @@ -42,7 +42,7 @@ export type UpdateQuery = (item: ActionInput, oldData: /** * A public query specifier used for addressing Wasp queries. See our docs for details: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates */ export type QuerySpecifier = [Query, ...any[]] @@ -116,7 +116,7 @@ type InternalAction = Action & { * * @param publicOptimisticUpdateDefinition An optimistic update definition * object that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns An internally-used optimistic update definition object. */ function translateToInternalDefinition( @@ -260,7 +260,7 @@ function getOptimisticUpdateDefinitionForSpecificItem( * Translates a Wasp query specifier to a query cache key used by React Query. * * @param querySpecifier A query specifier that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns A cache key React Query internally uses for addressing queries. */ function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier): QueryKey { diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.ts index d7532f65c6..17e36c1248 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api.ts @@ -8,59 +8,60 @@ const api = axios.create({ baseURL: config.apiUrl, }) -const WASP_APP_AUTH_TOKEN_NAME = 'authToken' +const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId' -let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined +let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined -export function setAuthToken(token: string): void { - authToken = token - storage.set(WASP_APP_AUTH_TOKEN_NAME, token) - apiEventsEmitter.emit('authToken.set') +export function setSessionId(sessionId: string): void { + waspAppAuthSessionId = sessionId + storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId) + apiEventsEmitter.emit('sessionId.set') } -export function getAuthToken(): string | undefined { - return authToken +export function getSessionId(): string | undefined { + return waspAppAuthSessionId } -export function clearAuthToken(): void { - authToken = undefined - storage.remove(WASP_APP_AUTH_TOKEN_NAME) - apiEventsEmitter.emit('authToken.clear') +export function clearSessionId(): void { + waspAppAuthSessionId = undefined + storage.remove(WASP_APP_AUTH_SESSION_ID_NAME) + apiEventsEmitter.emit('sessionId.clear') } export function removeLocalUserData(): void { - authToken = undefined + waspAppAuthSessionId = undefined storage.clear() - apiEventsEmitter.emit('authToken.clear') + apiEventsEmitter.emit('sessionId.clear') } api.interceptors.request.use((request) => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` + const sessionId = getSessionId() + if (sessionId) { + request.headers['Authorization'] = `Bearer ${sessionId}` } return request }) api.interceptors.response.use(undefined, (error) => { if (error.response?.status === 401) { - clearAuthToken() + clearSessionId() } return Promise.reject(error) }) // This handler will run on other tabs (not the active one calling API functions), -// and will ensure they know about auth token changes. +// and will ensure they know about auth session ID changes. // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event // "Note: This won't work on the same page that is making the changes — it is really a way // for other pages on the domain using the storage to sync any changes that are made." window.addEventListener('storage', (event) => { - if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) { + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { if (!!event.newValue) { - authToken = event.newValue - apiEventsEmitter.emit('authToken.set') + waspAppAuthSessionId = event.newValue + apiEventsEmitter.emit('sessionId.set') } else { - authToken = undefined - apiEventsEmitter.emit('authToken.clear') + waspAppAuthSessionId = undefined + apiEventsEmitter.emit('sessionId.clear') } } }) diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api/events.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api/events.ts index 9a59b366d3..a72e48dda8 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api/events.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/api/events.ts @@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt'; type ApiEvents = { // key: Event name // type: Event payload type - 'authToken.set': void; - 'authToken.clear': void; + 'sessionId.set': void; + 'sessionId.clear': void; }; -// Used to allow API clients to register for auth token change events. +// Used to allow API clients to register for auth session ID change events. export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest index a8c4f8eeb5..3738a312e8 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest @@ -29,6 +29,7 @@ waspMigrate/.wasp/out/server/src/entities/index.ts waspMigrate/.wasp/out/server/src/middleware/globalMiddleware.ts waspMigrate/.wasp/out/server/src/middleware/index.ts waspMigrate/.wasp/out/server/src/middleware/operations.ts +waspMigrate/.wasp/out/server/src/polyfill.ts waspMigrate/.wasp/out/server/src/queries/types.ts waspMigrate/.wasp/out/server/src/routes/index.js waspMigrate/.wasp/out/server/src/routes/operations/index.js diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums index a136b28147..4d50ee0978 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums @@ -174,6 +174,13 @@ ], "864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07" ], + [ + [ + "file", + "server/src/polyfill.ts" + ], + "66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf" + ], [ [ "file", @@ -200,14 +207,14 @@ "file", "server/src/server.ts" ], - "1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be" + "d0666b659cdc75db181ea2bbb50c4e157f0a7fbe00c4ff8fda0933b1a13e5a0e" ], [ [ "file", "server/src/types/index.ts" ], - "1958cfc3e3b5f59490168797e4b8dcdc38f32346e734f90df3fb6baa264b36b5" + "f7621082fc7d8467a0967eb0bd82ff7956052b766e9e82d50584b8de88e0d28a" ], [ [ @@ -333,21 +340,21 @@ "file", "web-app/src/actions/index.ts" ], - "3afb54edb61cbc95a9b2133f9b3bdc460ca97580aca700adad988bf0515ab092" + "607c3311861456ae47c246a950c8e29593f9837a9f5c48923d99cd7fac1ce0bb" ], [ [ "file", "web-app/src/api.ts" ], - "93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf" + "850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104" ], [ [ "file", "web-app/src/api/events.ts" ], - "7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044" + "91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/polyfill.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/polyfill.ts new file mode 100644 index 0000000000..a59302451a --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/polyfill.ts @@ -0,0 +1,7 @@ +// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it +// for random number generation. + +import { webcrypto } from "node:crypto"; + +// @ts-ignore +globalThis.crypto = webcrypto as Crypto; diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/server.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/server.ts index 1df46663f5..fff57f199b 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/server.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/server.ts @@ -6,6 +6,8 @@ import config from './config.js' +import './polyfill.js' + const startServer = async () => { const port = normalizePort(config.port) diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/types/index.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/types/index.ts index 9540b5dddc..b30c92f5ec 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/types/index.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/types/index.ts @@ -12,4 +12,3 @@ export type ServerSetupFnContext = { export type { Application } from 'express' export type { Server } from 'http' - diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.ts index 5e4dfedd12..7fb2de2f9e 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/actions/index.ts @@ -42,7 +42,7 @@ export type UpdateQuery = (item: ActionInput, oldData: /** * A public query specifier used for addressing Wasp queries. See our docs for details: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates */ export type QuerySpecifier = [Query, ...any[]] @@ -116,7 +116,7 @@ type InternalAction = Action & { * * @param publicOptimisticUpdateDefinition An optimistic update definition * object that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns An internally-used optimistic update definition object. */ function translateToInternalDefinition( @@ -260,7 +260,7 @@ function getOptimisticUpdateDefinitionForSpecificItem( * Translates a Wasp query specifier to a query cache key used by React Query. * * @param querySpecifier A query specifier that's a part of the public API: - * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * https://wasp-lang.dev/docs/data-model/operations/actions#the-useaction-hook-and-optimistic-updates * @returns A cache key React Query internally uses for addressing queries. */ function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier): QueryKey { diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.ts index d7532f65c6..17e36c1248 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api.ts @@ -8,59 +8,60 @@ const api = axios.create({ baseURL: config.apiUrl, }) -const WASP_APP_AUTH_TOKEN_NAME = 'authToken' +const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId' -let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined +let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined -export function setAuthToken(token: string): void { - authToken = token - storage.set(WASP_APP_AUTH_TOKEN_NAME, token) - apiEventsEmitter.emit('authToken.set') +export function setSessionId(sessionId: string): void { + waspAppAuthSessionId = sessionId + storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId) + apiEventsEmitter.emit('sessionId.set') } -export function getAuthToken(): string | undefined { - return authToken +export function getSessionId(): string | undefined { + return waspAppAuthSessionId } -export function clearAuthToken(): void { - authToken = undefined - storage.remove(WASP_APP_AUTH_TOKEN_NAME) - apiEventsEmitter.emit('authToken.clear') +export function clearSessionId(): void { + waspAppAuthSessionId = undefined + storage.remove(WASP_APP_AUTH_SESSION_ID_NAME) + apiEventsEmitter.emit('sessionId.clear') } export function removeLocalUserData(): void { - authToken = undefined + waspAppAuthSessionId = undefined storage.clear() - apiEventsEmitter.emit('authToken.clear') + apiEventsEmitter.emit('sessionId.clear') } api.interceptors.request.use((request) => { - if (authToken) { - request.headers['Authorization'] = `Bearer ${authToken}` + const sessionId = getSessionId() + if (sessionId) { + request.headers['Authorization'] = `Bearer ${sessionId}` } return request }) api.interceptors.response.use(undefined, (error) => { if (error.response?.status === 401) { - clearAuthToken() + clearSessionId() } return Promise.reject(error) }) // This handler will run on other tabs (not the active one calling API functions), -// and will ensure they know about auth token changes. +// and will ensure they know about auth session ID changes. // Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event // "Note: This won't work on the same page that is making the changes — it is really a way // for other pages on the domain using the storage to sync any changes that are made." window.addEventListener('storage', (event) => { - if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) { + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { if (!!event.newValue) { - authToken = event.newValue - apiEventsEmitter.emit('authToken.set') + waspAppAuthSessionId = event.newValue + apiEventsEmitter.emit('sessionId.set') } else { - authToken = undefined - apiEventsEmitter.emit('authToken.clear') + waspAppAuthSessionId = undefined + apiEventsEmitter.emit('sessionId.clear') } } }) diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api/events.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api/events.ts index 9a59b366d3..a72e48dda8 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api/events.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/api/events.ts @@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt'; type ApiEvents = { // key: Event name // type: Event payload type - 'authToken.set': void; - 'authToken.clear': void; + 'sessionId.set': void; + 'sessionId.clear': void; }; -// Used to allow API clients to register for auth token change events. +// Used to allow API clients to register for auth session ID change events. export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/examples/crud-testing/main.wasp b/waspc/examples/crud-testing/main.wasp index 1e1807d030..d75c9a86b0 100644 --- a/waspc/examples/crud-testing/main.wasp +++ b/waspc/examples/crud-testing/main.wasp @@ -9,12 +9,11 @@ app crudTesting { auth: { userEntity: User, methods: { - usernameAndPassword: {}, + usernameAndPassword: { + userSignupFields: import { userSignupFields } from "@server/auth.js", + }, }, onAuthFailedRedirectTo: "/login", - signup: { - additionalFields: import { fields } from "@server/auth.js", - }, }, dependencies: [ ("zod", "^3.22.2") diff --git a/waspc/examples/crud-testing/migrations/20231106110220_initial/migration.sql b/waspc/examples/crud-testing/migrations/20231106110220_initial/migration.sql deleted file mode 100644 index 08b9d8d3a0..0000000000 --- a/waspc/examples/crud-testing/migrations/20231106110220_initial/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "username" TEXT NOT NULL, - "password" TEXT NOT NULL, - "address" TEXT, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Task" ( - "id" SERIAL NOT NULL, - "title" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - - CONSTRAINT "Task_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); - --- AddForeignKey -ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql b/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql deleted file mode 100644 index 280d1f5fc5..0000000000 --- a/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql +++ /dev/null @@ -1,38 +0,0 @@ --- CreateTable -CREATE TABLE "Auth" ( - "id" TEXT NOT NULL, - "email" TEXT, - "username" TEXT, - "password" TEXT, - "isEmailVerified" BOOLEAN NOT NULL DEFAULT false, - "emailVerificationSentAt" TIMESTAMP(3), - "passwordResetSentAt" TIMESTAMP(3), - "userId" INTEGER, - - CONSTRAINT "Auth_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "SocialAuthProvider" ( - "id" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "providerId" TEXT NOT NULL, - "authId" TEXT NOT NULL, - - CONSTRAINT "SocialAuthProvider_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Auth_email_key" ON "Auth"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "Auth_username_key" ON "Auth"("username"); - --- CreateIndex -CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); - --- AddForeignKey -ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SocialAuthProvider" ADD CONSTRAINT "SocialAuthProvider_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql b/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql deleted file mode 100644 index dcc286e347..0000000000 --- a/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ -/* - Warnings: - - - You are about to drop the `SocialAuthProvider` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropForeignKey -ALTER TABLE "SocialAuthProvider" DROP CONSTRAINT "SocialAuthProvider_authId_fkey"; - --- DropTable -DROP TABLE "SocialAuthProvider"; - --- CreateTable -CREATE TABLE "AuthIdentity" ( - "providerName" TEXT NOT NULL, - "providerUserId" TEXT NOT NULL, - "providerData" TEXT NOT NULL DEFAULT '{}', - "authId" TEXT NOT NULL, - - CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId") -); - --- AddForeignKey -ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql b/waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql deleted file mode 100644 index 90ca6265e3..0000000000 --- a/waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `email` on the `Auth` table. All the data in the column will be lost. - - You are about to drop the column `emailVerificationSentAt` on the `Auth` table. All the data in the column will be lost. - - You are about to drop the column `isEmailVerified` on the `Auth` table. All the data in the column will be lost. - - You are about to drop the column `password` on the `Auth` table. All the data in the column will be lost. - - You are about to drop the column `passwordResetSentAt` on the `Auth` table. All the data in the column will be lost. - - You are about to drop the column `username` on the `Auth` table. All the data in the column will be lost. - -*/ --- DropIndex -DROP INDEX "Auth_email_key"; - --- DropIndex -DROP INDEX "Auth_username_key"; - --- AlterTable -ALTER TABLE "Auth" DROP COLUMN "email", -DROP COLUMN "emailVerificationSentAt", -DROP COLUMN "isEmailVerified", -DROP COLUMN "password", -DROP COLUMN "passwordResetSentAt", -DROP COLUMN "username"; diff --git a/waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql b/waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql deleted file mode 100644 index 5ad32a2c57..0000000000 --- a/waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. - - You are about to drop the column `username` on the `User` table. All the data in the column will be lost. - -*/ --- DropIndex -DROP INDEX "User_username_key"; - --- AlterTable -ALTER TABLE "User" DROP COLUMN "password", -DROP COLUMN "username"; diff --git a/waspc/examples/crud-testing/migrations/20240110121223_initial/migration.sql b/waspc/examples/crud-testing/migrations/20240110121223_initial/migration.sql new file mode 100644 index 0000000000..373557995f --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20240110121223_initial/migration.sql @@ -0,0 +1,64 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "address" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL, + "userId" INTEGER, + + CONSTRAINT "Auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/crud-testing/src/server/auth.ts b/waspc/examples/crud-testing/src/server/auth.ts index c0b111b628..981fcd33be 100644 --- a/waspc/examples/crud-testing/src/server/auth.ts +++ b/waspc/examples/crud-testing/src/server/auth.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' import { ensurePasswordIsPresent, ensureValidPassword, @@ -9,9 +9,8 @@ import prisma from '@wasp/dbClient.js' import { CustomSignup } from '@wasp/actions/types' import { sanitizeAndSerializeProviderData } from '@wasp/auth/utils.js' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: (data) => { - console.log('Received data:', data) const AddressSchema = z .string({ required_error: 'Address is required', diff --git a/waspc/examples/crud-testing/src/server/auth_simple.js b/waspc/examples/crud-testing/src/server/auth_simple.js index 52d6aad97e..ebac9fa278 100644 --- a/waspc/examples/crud-testing/src/server/auth_simple.js +++ b/waspc/examples/crud-testing/src/server/auth_simple.js @@ -1,5 +1,5 @@ -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from 'wasp/auth/index.js' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: (data) => data.address, }) diff --git a/waspc/examples/pg-vector-example/main.wasp b/waspc/examples/pg-vector-example/main.wasp index 8e0c31ca6a..8e869b72d0 100644 --- a/waspc/examples/pg-vector-example/main.wasp +++ b/waspc/examples/pg-vector-example/main.wasp @@ -1,6 +1,6 @@ app pgVectorExample { wasp: { - version: "^0.11.3" + version: "^0.12.0" }, title: "PG Vector Example", dependencies: [ diff --git a/waspc/examples/todo-typescript/.gitignore b/waspc/examples/todo-typescript/.gitignore new file mode 100644 index 0000000000..ab7cafccec --- /dev/null +++ b/waspc/examples/todo-typescript/.gitignore @@ -0,0 +1,4 @@ +/.wasp/ +/.env.server +/.env.client +/node_modules/ diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/events.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/events.ts new file mode 100644 index 0000000000..a72e48dda8 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/events.ts @@ -0,0 +1,11 @@ +import mitt, { Emitter } from 'mitt'; + +type ApiEvents = { + // key: Event name + // type: Event payload type + 'sessionId.set': void; + 'sessionId.clear': void; +}; + +// Used to allow API clients to register for auth session ID change events. +export const apiEventsEmitter: Emitter = mitt(); diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/index.ts new file mode 100644 index 0000000000..8b22dd7ebc --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/api/index.ts @@ -0,0 +1,104 @@ +import axios, { type AxiosError } from 'axios' + +import config from 'wasp/core/config' +import { storage } from 'wasp/core/storage' +import { apiEventsEmitter } from 'wasp/api/events' + +const api = axios.create({ + baseURL: config.apiUrl, +}) + +const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId' + +let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined + +export function setSessionId(sessionId: string): void { + waspAppAuthSessionId = sessionId + storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId) + apiEventsEmitter.emit('sessionId.set') +} + +export function getSessionId(): string | undefined { + return waspAppAuthSessionId +} + +export function clearSessionId(): void { + waspAppAuthSessionId = undefined + storage.remove(WASP_APP_AUTH_SESSION_ID_NAME) + apiEventsEmitter.emit('sessionId.clear') +} + +export function removeLocalUserData(): void { + waspAppAuthSessionId = undefined + storage.clear() + apiEventsEmitter.emit('sessionId.clear') +} + +api.interceptors.request.use((request) => { + const sessionId = getSessionId() + if (sessionId) { + request.headers['Authorization'] = `Bearer ${sessionId}` + } + return request +}) + +api.interceptors.response.use(undefined, (error) => { + if (error.response?.status === 401) { + clearSessionId() + } + return Promise.reject(error) +}) + +// This handler will run on other tabs (not the active one calling API functions), +// and will ensure they know about auth session ID changes. +// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event +// "Note: This won't work on the same page that is making the changes — it is really a way +// for other pages on the domain using the storage to sync any changes that are made." +window.addEventListener('storage', (event) => { + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { + if (!!event.newValue) { + waspAppAuthSessionId = event.newValue + apiEventsEmitter.emit('sessionId.set') + } else { + waspAppAuthSessionId = undefined + apiEventsEmitter.emit('sessionId.clear') + } + } +}) + +/** + * Takes an error returned by the app's API (as returned by axios), and transforms into a more + * standard format to be further used by the client. It is also assumed that given API + * error has been formatted as implemented by HttpError on the server. + */ +export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void { + if (error?.response) { + // If error came from HTTP response, we capture most informative message + // and also add .statusCode information to it. + // If error had JSON response, we assume it is of format { message, data } and + // add that info to the error. + // TODO: We might want to use HttpError here instead of just Error, since + // HttpError is also used on server to throw errors like these. + // That would require copying HttpError code to web-app also and using it here. + const responseJson = error.response?.data + const responseStatusCode = error.response.status + throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson) + } else { + // If any other error, we just propagate it. + throw error + } +} + +class WaspHttpError extends Error { + statusCode: number + + data: unknown + + constructor (statusCode: number, message: string, data: unknown) { + super(message) + this.statusCode = statusCode + this.data = data + } +} + +export default api diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Auth.tsx b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Auth.tsx new file mode 100644 index 0000000000..92c58131f6 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Auth.tsx @@ -0,0 +1,85 @@ +import { useState, createContext } from 'react' +import { createTheme } from '@stitches/react' +import { styled } from 'wasp/core/stitches.config' + +import { + type State, + type CustomizationOptions, + type ErrorMessage, + type AdditionalSignupFields, +} from './types' +import { LoginSignupForm } from './internal/common/LoginSignupForm' +import { MessageError, MessageSuccess } from './internal/Message' + +const logoStyle = { + height: '3rem' +} + +const Container = styled('div', { + display: 'flex', + flexDirection: 'column', +}) + +const HeaderText = styled('h2', { + fontSize: '1.875rem', + fontWeight: '700', + marginTop: '1.5rem' +}) + + +export const AuthContext = createContext({ + isLoading: false, + setIsLoading: (isLoading: boolean) => {}, + setErrorMessage: (errorMessage: ErrorMessage | null) => {}, + setSuccessMessage: (successMessage: string | null) => {}, +}) + +function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: { + state: State; +} & CustomizationOptions & { + additionalSignupFields?: AdditionalSignupFields; +}) { + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // TODO(matija): this is called on every render, is it a problem? + // If we do it in useEffect(), then there is a glitch between the default color and the + // user provided one. + const customTheme = createTheme(appearance ?? {}) + + const titles: Record = { + login: 'Log in to your account', + signup: 'Create a new account', + } + const title = titles[state] + + const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal' + + return ( + +
+ {logo && (Your Company)} + {title} +
+ + {errorMessage && ( + + {errorMessage.title}{errorMessage.description && ': '}{errorMessage.description} + + )} + {successMessage && {successMessage}} + + {(state === 'login' || state === 'signup') && ( + + )} + +
+ ) +} + +export default Auth; diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Login.tsx b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Login.tsx new file mode 100644 index 0000000000..2ea532d9c5 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Login.tsx @@ -0,0 +1,17 @@ +import Auth from './Auth' +import { type CustomizationOptions, State } from './types' + +export function LoginForm({ + appearance, + logo, + socialLayout, +}: CustomizationOptions) { + return ( + + ) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Signup.tsx b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Signup.tsx new file mode 100644 index 0000000000..66ffab4503 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/Signup.tsx @@ -0,0 +1,23 @@ +import Auth from './Auth' +import { + type CustomizationOptions, + type AdditionalSignupFields, + State, +} from './types' + +export function SignupForm({ + appearance, + logo, + socialLayout, + additionalFields, +}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) { + return ( + + ) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/Form.tsx b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/Form.tsx new file mode 100644 index 0000000000..781c75a0ae --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/Form.tsx @@ -0,0 +1,95 @@ +import { styled } from 'wasp/core/stitches.config' + +export const Form = styled('form', { + marginTop: '1.5rem', +}) + +export const FormItemGroup = styled('div', { + '& + div': { + marginTop: '1.5rem', + }, +}) + +export const FormLabel = styled('label', { + display: 'block', + fontSize: '$sm', + fontWeight: '500', + marginBottom: '0.5rem', +}) + +const commonInputStyles = { + display: 'block', + lineHeight: '1.5rem', + fontSize: '$sm', + borderWidth: '1px', + borderColor: '$gray600', + backgroundColor: '#f8f4ff', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + '&:focus': { + borderWidth: '1px', + borderColor: '$gray700', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + }, + '&:disabled': { + opacity: 0.5, + cursor: 'not-allowed', + backgroundColor: '$gray400', + borderColor: '$gray400', + color: '$gray500', + }, + + borderRadius: '0.375rem', + width: '100%', + + paddingTop: '0.375rem', + paddingBottom: '0.375rem', + paddingLeft: '0.75rem', + paddingRight: '0.75rem', + margin: 0, +} + +export const FormInput = styled('input', commonInputStyles) + +export const FormTextarea = styled('textarea', commonInputStyles) + +export const FormError = styled('div', { + display: 'block', + fontSize: '$sm', + fontWeight: '500', + color: '$formErrorText', + marginTop: '0.5rem', +}) + +export const SubmitButton = styled('button', { + display: 'flex', + justifyContent: 'center', + + width: '100%', + borderWidth: '1px', + borderColor: '$brand', + backgroundColor: '$brand', + color: '$submitButtonText', + + padding: '0.5rem 0.75rem', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + + fontWeight: '600', + fontSize: '$sm', + lineHeight: '1.25rem', + borderRadius: '0.375rem', + + // TODO(matija): extract this into separate BaseButton component and then inherit it. + '&:hover': { + backgroundColor: '$brandAccent', + borderColor: '$brandAccent', + }, + '&:disabled': { + opacity: 0.5, + cursor: 'not-allowed', + backgroundColor: '$gray400', + borderColor: '$gray400', + color: '$gray500', + }, + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionDuration: '100ms', +}) diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/Message.tsx b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/Message.tsx new file mode 100644 index 0000000000..7279ed2525 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/Message.tsx @@ -0,0 +1,18 @@ +import { styled } from 'wasp/core/stitches.config' + +export const Message = styled('div', { + padding: '0.5rem 0.75rem', + borderRadius: '0.375rem', + marginTop: '1rem', + background: '$gray400', +}) + +export const MessageError = styled(Message, { + background: '$errorBackground', + color: '$errorText', +}) + +export const MessageSuccess = styled(Message, { + background: '$successBackground', + color: '$successText', +}) diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx new file mode 100644 index 0000000000..30665b4759 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx @@ -0,0 +1,178 @@ +import { useContext } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import { styled } from 'wasp/core/stitches.config' +import config from 'wasp/core/config' + +import { AuthContext } from '../../Auth' +import { + Form, + FormInput, + FormItemGroup, + FormLabel, + FormError, + FormTextarea, + SubmitButton, +} from '../Form' +import type { + AdditionalSignupFields, + AdditionalSignupField, + AdditionalSignupFieldRenderFn, + FormState, +} from '../../types' +import { useHistory } from 'react-router-dom' +import { useUsernameAndPassword } from '../usernameAndPassword/useUsernameAndPassword' + + +export type LoginSignupFormFields = { + [key: string]: string; +} + +export const LoginSignupForm = ({ + state, + socialButtonsDirection = 'horizontal', + additionalSignupFields, +}: { + state: 'login' | 'signup' + socialButtonsDirection?: 'horizontal' | 'vertical' + additionalSignupFields?: AdditionalSignupFields +}) => { + const { + isLoading, + setErrorMessage, + setSuccessMessage, + setIsLoading, + } = useContext(AuthContext) + const isLogin = state === 'login' + const cta = isLogin ? 'Log in' : 'Sign up'; + const history = useHistory(); + const onErrorHandler = (error) => { + setErrorMessage({ title: error.message, description: error.data?.data?.message }) + }; + const hookForm = useForm() + const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm + const { handleSubmit } = useUsernameAndPassword({ + isLogin, + onError: onErrorHandler, + onSuccess() { + history.push('/') + }, + }); + async function onSubmit (data) { + setIsLoading(true); + setErrorMessage(null); + setSuccessMessage(null); + try { + await handleSubmit(data); + } finally { + setIsLoading(false); + } + } + + return (<> +
+ + Username + + {errors.username && {errors.username.message}} + + + Password + + {errors.password && {errors.password.message}} + + + + {cta} + + + ) +} + +function AdditionalFormFields({ + hookForm, + formState: { isLoading }, + additionalSignupFields, +}: { + hookForm: UseFormReturn; + formState: FormState; + additionalSignupFields: AdditionalSignupFields; +}) { + const { + register, + formState: { errors }, + } = hookForm; + + function renderField>( + field: AdditionalSignupField, + // Ideally we would use ComponentType here, but it doesn't work with react-hook-form + Component: any, + props?: React.ComponentProps + ) { + return ( + + {field.label} + + {errors[field.name] && ( + {errors[field.name].message} + )} + + ); + } + + if (areAdditionalFieldsRenderFn(additionalSignupFields)) { + return additionalSignupFields(hookForm, { isLoading }) + } + + return ( + additionalSignupFields && + additionalSignupFields.map((field) => { + if (isFieldRenderFn(field)) { + return field(hookForm, { isLoading }) + } + switch (field.type) { + case 'input': + return renderField(field, FormInput, { + type: 'text', + }) + case 'textarea': + return renderField(field, FormTextarea) + default: + throw new Error( + `Unsupported additional signup field type: ${field.type}` + ) + } + }) + ) +} + +function isFieldRenderFn( + additionalSignupField: AdditionalSignupField | AdditionalSignupFieldRenderFn +): additionalSignupField is AdditionalSignupFieldRenderFn { + return typeof additionalSignupField === 'function' +} + +function areAdditionalFieldsRenderFn( + additionalSignupFields: AdditionalSignupFields +): additionalSignupFields is AdditionalSignupFieldRenderFn { + return typeof additionalSignupFields === 'function' +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts new file mode 100644 index 0000000000..247c1faeb4 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts @@ -0,0 +1,29 @@ +import signup from '../../../signup' +import login from '../../../login' + +export function useUsernameAndPassword({ + onError, + onSuccess, + isLogin, +}: { + onError: (error: Error) => void + onSuccess: () => void + isLogin: boolean +}) { + async function handleSubmit(data) { + try { + if (!isLogin) { + await signup(data) + } + await login(data.username, data.password) + + onSuccess() + } catch (err: unknown) { + onError(err as Error) + } + } + + return { + handleSubmit, + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/types.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/types.ts new file mode 100644 index 0000000000..14d61ad51e --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/forms/types.ts @@ -0,0 +1,39 @@ +import { createTheme } from '@stitches/react' +import { UseFormReturn, RegisterOptions } from 'react-hook-form' +import type { LoginSignupFormFields } from './internal/common/LoginSignupForm' + +export enum State { + Login = 'login', + Signup = 'signup', +} + +export type CustomizationOptions = { + logo?: string + socialLayout?: 'horizontal' | 'vertical' + appearance?: Parameters[0] +} + +export type ErrorMessage = { + title: string + description?: string +} + +export type FormState = { + isLoading: boolean +} + +export type AdditionalSignupFieldRenderFn = ( + hookForm: UseFormReturn, + formState: FormState +) => React.ReactNode + +export type AdditionalSignupField = { + name: string + label: string + type: 'input' | 'textarea' + validations?: RegisterOptions +} + +export type AdditionalSignupFields = + | (AdditionalSignupField | AdditionalSignupFieldRenderFn)[] + | AdditionalSignupFieldRenderFn diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/helpers/user.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/helpers/user.ts new file mode 100644 index 0000000000..498f2588a8 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/helpers/user.ts @@ -0,0 +1,14 @@ +import { setSessionId } from 'wasp/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export async function initSession(sessionId: string): Promise { + setSessionId(sessionId) + // We need to invalidate queries after login in order to get the correct user + // data in the React components (using `useAuth`). + // Redirects after login won't work properly without this. + + // TODO(filip): We are currently removing all the queries, but we should + // remove only non-public, user-dependent queries - public queries are + // expected not to change in respect to the currently logged in user. + await invalidateAndRemoveQueries() +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/login.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/login.ts new file mode 100644 index 0000000000..2b4ec4b9fe --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/login.ts @@ -0,0 +1,13 @@ +import api, { handleApiError } from 'wasp/api' +import { initSession } from './helpers/user' + +export default async function login(username: string, password: string): Promise { + try { + const args = { username, password } + const response = await api.post('/auth/username/login', args) + + await initSession(response.data.sessionId) + } catch (error) { + handleApiError(error) + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/logout.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/logout.ts new file mode 100644 index 0000000000..cc41b6989c --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/logout.ts @@ -0,0 +1,17 @@ +import api, { removeLocalUserData } from 'wasp/api' +import { invalidateAndRemoveQueries } from 'wasp/operations/resources' + +export default async function logout(): Promise { + try { + await api.post('/auth/logout') + } finally { + // Even if the logout request fails, we still want to remove the local user data + // in case the logout failed because of a network error and the user walked away + // from the computer. + removeLocalUserData() + + // TODO(filip): We are currently invalidating and removing all the queries, but + // we should remove only the non-public, user-dependent ones. + await invalidateAndRemoveQueries() + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/pages/createAuthRequiredPage.jsx b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/pages/createAuthRequiredPage.jsx new file mode 100644 index 0000000000..621ef393d9 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/pages/createAuthRequiredPage.jsx @@ -0,0 +1,30 @@ +import React from 'react' + +import { Redirect } from 'react-router-dom' +import useAuth from '../useAuth' + + +const createAuthRequiredPage = (Page) => { + return (props) => { + const { data: user, isError, isSuccess, isLoading } = useAuth() + + if (isSuccess) { + if (user) { + return ( + + ) + } else { + return + } + } else if (isLoading) { + return Loading... + } else if (isError) { + return An error ocurred. Please refresh the page. + } else { + return An unknown error ocurred. Please refresh the page. + } + } +} + +export default createAuthRequiredPage + diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/providers/types.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/providers/types.ts new file mode 100644 index 0000000000..76e1114850 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/providers/types.ts @@ -0,0 +1,40 @@ +import type { Router, Request } from 'express' +import type { Prisma } from '@prisma/client' +import type { Expand } from 'wasp/universal/types' +import type { ProviderName } from '../utils' + +type UserEntityCreateInput = Prisma.UserCreateInput + +export type ProviderConfig = { + // Unique provider identifier, used as part of URL paths + id: ProviderName; + displayName: string; + // Each provider config can have an init method which is ran on setup time + // e.g. for oAuth providers this is the time when the Passport strategy is registered. + init?(provider: ProviderConfig): Promise; + // Every provider must have a setupRouter method which returns the Express router. + // In this function we are flexibile to do what ever is necessary to make the provider work. + createRouter(provider: ProviderConfig, initData: InitData): Router; +}; + +export type InitData = { + [key: string]: any; +} + +export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } + +export type PossibleUserFields = Expand> + +export type UserSignupFields = { + [key in keyof PossibleUserFields]: FieldGetter< + PossibleUserFields[key] + > +} + +type FieldGetter = ( + data: { [key: string]: unknown } +) => Promise | T | undefined + +export function defineUserSignupFields(fields: UserSignupFields) { + return fields +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/signup.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/signup.ts new file mode 100644 index 0000000000..bde50c5ebd --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/signup.ts @@ -0,0 +1,9 @@ +import api, { handleApiError } from 'wasp/api' + +export default async function signup(userFields: { username: string; password: string }): Promise { + try { + await api.post('/auth/username/signup', userFields) + } catch (error) { + handleApiError(error) + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/types.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/types.ts new file mode 100644 index 0000000000..f9f079a57a --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/types.ts @@ -0,0 +1,2 @@ +// todo(filip): turn into a proper import/path +export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/' diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/useAuth.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/useAuth.ts new file mode 100644 index 0000000000..29b95f62a0 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/useAuth.ts @@ -0,0 +1,38 @@ +import { deserialize as superjsonDeserialize } from 'superjson' +import { useQuery } from 'wasp/rpc' +import api, { handleApiError } from 'wasp/api' +import { HttpMethod } from 'wasp/types' +import type { User } from './types' +import { addMetadataToQuery } from 'wasp/rpc/queries' + +export const getMe = createUserGetter() + +export default function useAuth(queryFnArgs?: unknown, config?: any) { + return useQuery(getMe, queryFnArgs, config) +} + +function createUserGetter() { + const getMeRelativePath = 'auth/me' + const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` } + async function getMe(): Promise { + try { + const response = await api.get(getMeRoute.path) + + return superjsonDeserialize(response.data) + } catch (error) { + if (error.response?.status === 401) { + return null + } else { + handleApiError(error) + } + } + } + + addMetadataToQuery(getMe, { + relativeQueryPath: getMeRelativePath, + queryRoute: getMeRoute, + entitiesUsed: ['User'], + }) + + return getMe +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/user.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/user.ts new file mode 100644 index 0000000000..aa0da24824 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/user.ts @@ -0,0 +1,27 @@ +// We decided not to deduplicate these helper functions in the server and the client. +// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts +// If you are changing the logic here, make sure to change it there as well. + +import type { User, ProviderName, DeserializedAuthIdentity } from './types' + +export function getEmail(user: User): string | null { + return findUserIdentity(user, "email")?.providerUserId ?? null; +} + +export function getUsername(user: User): string | null { + return findUserIdentity(user, "username")?.providerUserId ?? null; +} + +export function getFirstProviderUserId(user?: User): string | null { + if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) { + return null; + } + + return user.auth.identities[0].providerUserId ?? null; +} + +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined { + return user.auth.identities.find( + (identity) => identity.providerName === providerName + ); +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/utils.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/utils.ts new file mode 100644 index 0000000000..15f8531261 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/utils.ts @@ -0,0 +1,302 @@ +import { hashPassword } from './password.js' +import { verify } from './jwt.js' +import AuthError from 'wasp/core/AuthError' +import HttpError from 'wasp/core/HttpError' +import prisma from 'wasp/server/dbClient' +import { sleep } from 'wasp/server/utils' +import { + type User, + type Auth, + type AuthIdentity, +} from 'wasp/entities' +import { Prisma } from '@prisma/client'; + +import { throwValidationError } from './validation.js' + +import { type UserSignupFields, type PossibleUserFields } from './providers/types.js' + +export type EmailProviderData = { + hashedPassword: string; + isEmailVerified: boolean; + emailVerificationSentAt: string | null; + passwordResetSentAt: string | null; +} + +export type UsernameProviderData = { + hashedPassword: string; +} + +export type OAuthProviderData = {} + +/** + * This type is used for type-level programming e.g. to enumerate + * all possible provider data types. + * + * The keys of this type are the names of the providers and the values + * are the types of the provider data. + */ +export type PossibleProviderData = { + email: EmailProviderData; + username: UsernameProviderData; + google: OAuthProviderData; + github: OAuthProviderData; +} + +export type ProviderName = keyof PossibleProviderData + +export const contextWithUserEntity = { + entities: { + User: prisma.user + } +} + +export const authConfig = { + failureRedirectPath: "/login", + successRedirectPath: "/", +} + +/** + * ProviderId uniquely identifies an auth identity e.g. + * "email" provider with user id "test@test.com" or + * "google" provider with user id "1234567890". + * + * We use this type to avoid passing the providerName and providerUserId + * separately. Also, we can normalize the providerUserId to make sure it's + * consistent across different DB operations. + */ +export type ProviderId = { + providerName: ProviderName; + providerUserId: string; +} + +export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId { + return { + providerName, + providerUserId: providerUserId.toLowerCase(), + } +} + +export async function findAuthIdentity(providerId: ProviderId): Promise { + return prisma.authIdentity.findUnique({ + where: { + providerName_providerUserId: providerId, + } + }); +} + +/** + * Updates the provider data for the given auth identity. + * + * This function performs data sanitization and serialization. + * Sanitization is done by hashing the password, so this function + * expects the password received in the `providerDataUpdates` + * **not to be hashed**. + */ +export async function updateAuthIdentityProviderData( + providerId: ProviderId, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, +): Promise { + // We are doing the sanitization here only on updates to avoid + // hashing the password multiple times. + const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates); + const newProviderData = { + ...existingProviderData, + ...sanitizedProviderDataUpdates, + } + const serializedProviderData = await serializeProviderData(newProviderData); + return prisma.authIdentity.update({ + where: { + providerName_providerUserId: providerId, + }, + data: { providerData: serializedProviderData }, + }); +} + +type FindAuthWithUserResult = Auth & { + user: User +} + +export async function findAuthWithUserBy( + where: Prisma.AuthWhereInput +): Promise { + return prisma.auth.findFirst({ where, include: { user: true }}); +} + +export async function createUser( + providerId: ProviderId, + serializedProviderData?: string, + userFields?: PossibleUserFields, +): Promise { + return prisma.user.create({ + data: { + // Using any here to prevent type errors when userFields are not + // defined. We want Prisma to throw an error in that case. + ...(userFields ?? {} as any), + auth: { + create: { + identities: { + create: { + providerName: providerId.providerName, + providerUserId: providerId.providerUserId, + providerData: serializedProviderData, + }, + }, + } + }, + }, + // We need to include the Auth entity here because we need `authId` + // to be able to create a session. + include: { + auth: true, + }, + }) +} + +export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> { + return prisma.user.deleteMany({ where: { auth: { + id: authId, + } } }) +} + +export async function verifyToken(token: string): Promise { + return verify(token); +} + +// If an user exists, we don't want to leak information +// about it. Pretending that we're doing some work +// will make it harder for an attacker to determine +// if a user exists or not. +// NOTE: Attacker measuring time to response can still determine +// if a user exists or not. We'll be able to avoid it when +// we implement e-mail sending via jobs. +export async function doFakeWork(): Promise { + const timeToWork = Math.floor(Math.random() * 1000) + 1000; + return sleep(timeToWork); +} + +export function rethrowPossibleAuthError(e: unknown): void { + if (e instanceof AuthError) { + throwValidationError((e as any).message); + } + + // Prisma code P2002 is for unique constraint violations. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpError(422, 'Save failed', { + message: `user with the same identity already exists`, + }) + } + + if (e instanceof Prisma.PrismaClientValidationError) { + // NOTE: Logging the error since this usually means that there are + // required fields missing in the request, we want the developer + // to know about it. + console.error(e) + throw new HttpError(422, 'Save failed', { + message: 'there was a database error' + }) + } + + // Prisma code P2021 is for missing table errors. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') { + // NOTE: Logging the error since this usually means that the database + // migrations weren't run, we want the developer to know about it. + console.error(e) + console.info('🐝 This error can happen if you did\'t run the database migrations.') + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + // Prisma code P2003 is for foreign key constraint failure + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') { + console.error(e) + console.info(`🐝 This error can happen if you have some relation on your User entity + but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull". + Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`) + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + throw e +} + +export async function validateAndGetUserFields( + data: { + [key: string]: unknown + }, + userSignupFields?: UserSignupFields, +): Promise> { + const { + password: _password, + ...sanitizedData + } = data; + const result: Record = {}; + + if (!userSignupFields) { + return result; + } + + for (const [field, getFieldValue] of Object.entries(userSignupFields)) { + try { + const value = await getFieldValue(sanitizedData) + result[field] = value + } catch (e) { + throwValidationError(e.message) + } + } + return result; +} + +export function deserializeAndSanitizeProviderData( + providerData: string, + { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {}, +): PossibleProviderData[PN] { + // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON. + let data = JSON.parse(providerData) as PossibleProviderData[PN]; + + if (providerDataHasPasswordField(data) && shouldRemovePasswordField) { + delete data.hashedPassword; + } + + return data; +} + +export async function sanitizeAndSerializeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + return serializeProviderData( + await sanitizeProviderData(providerData) + ); +} + +function serializeProviderData(providerData: PossibleProviderData[PN]): string { + return JSON.stringify(providerData); +} + +async function sanitizeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + const data = { + ...providerData, + }; + if (providerDataHasPasswordField(data)) { + data.hashedPassword = await hashPassword(data.hashedPassword); + } + + return data; +} + + +function providerDataHasPasswordField( + providerData: PossibleProviderData[keyof PossibleProviderData], +): providerData is { hashedPassword: string } { + return 'hashedPassword' in providerData; +} + +export function throwInvalidCredentialsError(message?: string): void { + throw new HttpError(401, 'Invalid credentials', { message }) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/validation.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/validation.ts new file mode 100644 index 0000000000..73bac13e21 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/auth/validation.ts @@ -0,0 +1,77 @@ +import HttpError from 'wasp/core/HttpError'; + +export const PASSWORD_FIELD = 'password'; +const USERNAME_FIELD = 'username'; +const EMAIL_FIELD = 'email'; +const TOKEN_FIELD = 'token'; + +export function ensureValidEmail(args: unknown): void { + validate(args, [ + { validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email }, + { validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) }, + ]); +} + +export function ensureValidUsername(args: unknown): void { + validate(args, [ + { validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username } + ]); +} + +export function ensurePasswordIsPresent(args: unknown): void { + validate(args, [ + { validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password }, + ]); +} + +export function ensureValidPassword(args: unknown): void { + validate(args, [ + { validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => isMinLength(password, 8) }, + { validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => containsNumber(password) }, + ]); +} + +export function ensureTokenIsPresent(args: unknown): void { + validate(args, [ + { validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token }, + ]); +} + +export function throwValidationError(message: string): void { + throw new HttpError(422, 'Validation failed', { message }) +} + +function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void { + for (const { validates, message, validator } of validators) { + if (!validator(args[validates])) { + throwValidationError(message); + } + } +} + +// NOTE(miho): it would be good to replace our custom validations with e.g. Zod + +const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ +function isValidEmail(input: unknown): boolean { + if (typeof input !== 'string') { + return false + } + + return input.match(validEmailRegex) !== null +} + +function isMinLength(input: unknown, minLength: number): boolean { + if (typeof input !== 'string') { + return false + } + + return input.length >= minLength +} + +function containsNumber(input: unknown): boolean { + if (typeof input !== 'string') { + return false + } + + return /\d/.test(input) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/AuthError.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/AuthError.js new file mode 100644 index 0000000000..2d965c168e --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/AuthError.js @@ -0,0 +1,17 @@ +class AuthError extends Error { + constructor (message, data, ...params) { + super(message, ...params) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AuthError) + } + + this.name = this.constructor.name + + if (data) { + this.data = data + } + } +} + +export default AuthError diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/HttpError.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/HttpError.js new file mode 100644 index 0000000000..8a2cb04db5 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/HttpError.js @@ -0,0 +1,22 @@ +class HttpError extends Error { + constructor (statusCode, message, data, ...params) { + super(message, ...params) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HttpError) + } + + this.name = this.constructor.name + + if (!(Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600)) { + throw new Error('statusCode has to be integer in range [400, 600).') + } + this.statusCode = statusCode + + if (data) { + this.data = data + } + } +} + +export default HttpError diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/auth.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/auth.js new file mode 100644 index 0000000000..2408af794c --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/auth.js @@ -0,0 +1,38 @@ +import { handleRejection } from 'wasp/server/utils' +import { getSessionAndUserFromBearerToken } from 'wasp/auth/session' +import { throwInvalidCredentialsError } from 'wasp/auth/utils' + +/** + * Auth middleware + * + * If the request includes an `Authorization` header it will try to authenticate the request, + * otherwise it will let the request through. + * + * - If authentication succeeds it sets `req.sessionId` and `req.user` + * - `req.user` is the user that made the request and it's used in + * all Wasp features that need to know the user that made the request. + * - `req.sessionId` is the ID of the session that authenticated the request. + * - If the request is not authenticated, it throws an error. + */ +const auth = handleRejection(async (req, res, next) => { + const authHeader = req.get('Authorization') + if (!authHeader) { + // NOTE(matija): for now we let tokenless requests through and make it operation's + // responsibility to verify whether the request is authenticated or not. In the future + // we will develop our own system at Wasp-level for that. + return next() + } + + const { session, user } = await getSessionAndUserFromBearerToken(req); + + if (!session || !user) { + throwInvalidCredentialsError() + } + + req.sessionId = session.id + req.user = user + + next() +}) + +export default auth diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/config.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/config.js new file mode 100644 index 0000000000..e9234e6f2a --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/config.js @@ -0,0 +1,9 @@ +import { stripTrailingSlash } from 'wasp/universal/url' + +const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || 'http://localhost:3001'; + +const config = { + apiUrl, +} + +export default config diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/stitches.config.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/stitches.config.js new file mode 100644 index 0000000000..c1d600a3f6 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/stitches.config.js @@ -0,0 +1,33 @@ +import { createStitches } from '@stitches/react' + +export const { + styled, + css +} = createStitches({ + theme: { + colors: { + waspYellow: '#ffcc00', + gray700: '#a1a5ab', + gray600: '#d1d5db', + gray500: 'gainsboro', + gray400: '#f0f0f0', + red: '#FED7D7', + darkRed: '#fa3838', + green: '#C6F6D5', + + brand: '$waspYellow', + brandAccent: '#ffdb46', + errorBackground: '$red', + errorText: '#2D3748', + successBackground: '$green', + successText: '#2D3748', + + submitButtonText: 'black', + + formErrorText: '$darkRed', + }, + fontSizes: { + sm: '0.875rem' + } + } +}) diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/storage.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/storage.ts new file mode 100644 index 0000000000..0321acea8b --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/core/storage.ts @@ -0,0 +1,50 @@ +export type DataStore = { + getPrefixedKey(key: string): string + set(key: string, value: unknown): void + get(key: string): unknown + remove(key: string): void + clear(): void +} + +function createLocalStorageDataStore(prefix: string): DataStore { + function getPrefixedKey(key: string): string { + return `${prefix}:${key}` + } + + return { + getPrefixedKey, + set(key, value) { + ensureLocalStorageIsAvailable() + localStorage.setItem(getPrefixedKey(key), JSON.stringify(value)) + }, + get(key) { + ensureLocalStorageIsAvailable() + const value = localStorage.getItem(getPrefixedKey(key)) + try { + return value ? JSON.parse(value) : undefined + } catch (e: any) { + return undefined + } + }, + remove(key) { + ensureLocalStorageIsAvailable() + localStorage.removeItem(getPrefixedKey(key)) + }, + clear() { + ensureLocalStorageIsAvailable() + Object.keys(localStorage).forEach((key) => { + if (key.startsWith(prefix)) { + localStorage.removeItem(key) + } + }) + }, + } +} + +export const storage = createLocalStorageDataStore('wasp') + +function ensureLocalStorageIsAvailable(): void { + if (!window.localStorage) { + throw new Error('Local storage is not available.') + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/entities/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/entities/index.ts new file mode 100644 index 0000000000..5febac3804 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/entities/index.ts @@ -0,0 +1,21 @@ +import { + type User, + type Task, +} from "@prisma/client" + +export { + type User, + type Task, + type Auth, + type AuthIdentity, +} from "@prisma/client" + +export type Entity = + | User + | Task + | never + +export type EntityName = + | "User" + | "Task" + | never diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/index.ts new file mode 100644 index 0000000000..31e70ae98b --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/index.ts @@ -0,0 +1,22 @@ +import api, { handleApiError } from 'wasp/api' +import { HttpMethod } from 'wasp/types' +import { + serialize as superjsonSerialize, + deserialize as superjsonDeserialize, +} from 'superjson' + +export type OperationRoute = { method: HttpMethod, path: string } + +export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) { + try { + const superjsonArgs = superjsonSerialize(args) + const response = await api.post(operationRoute.path, superjsonArgs) + return superjsonDeserialize(response.data) + } catch (error) { + handleApiError(error) + } +} + +export function makeOperationRoute(relativeOperationRoute: string): OperationRoute { + return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/resources.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/resources.js new file mode 100644 index 0000000000..5261654600 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/resources.js @@ -0,0 +1,81 @@ +import { queryClientInitialized } from 'wasp/rpc/queryClient' +import { makeUpdateHandlersMap } from './updateHandlersMap' +import { hashQueryKey } from '@tanstack/react-query' + +// Map where key is resource name and value is Set +// containing query ids of all the queries that use +// that resource. +const resourceToQueryCacheKeys = new Map() + +const updateHandlers = makeUpdateHandlersMap(hashQueryKey) +/** + * Remembers that specified query is using specified resources. + * If called multiple times for same query, resources are added, not reset. + * @param {string[]} queryCacheKey - Unique key under used to identify query in the cache. + * @param {string[]} resources - Names of resources that query is using. + */ +export function addResourcesUsedByQuery(queryCacheKey, resources) { + for (const resource of resources) { + let cacheKeys = resourceToQueryCacheKeys.get(resource) + if (!cacheKeys) { + cacheKeys = new Set() + resourceToQueryCacheKeys.set(resource, cacheKeys) + } + cacheKeys.add(queryCacheKey) + } +} + +export function registerActionInProgress(optimisticUpdateTuples) { + optimisticUpdateTuples.forEach( + ({ queryKey, updateQuery }) => updateHandlers.add(queryKey, updateQuery) + ) +} + +export async function registerActionDone(resources, optimisticUpdateTuples) { + optimisticUpdateTuples.forEach(({ queryKey }) => updateHandlers.remove(queryKey)) + await invalidateQueriesUsing(resources) +} + +export function getActiveOptimisticUpdates(queryKey) { + return updateHandlers.getUpdateHandlers(queryKey) +} + +export async function invalidateAndRemoveQueries() { + const queryClient = await queryClientInitialized + // If we don't reset the queries before removing them, Wasp will stay on + // the same page. The user would have to manually refresh the page to "finish" + // logging out. + // When a query is removed, the `Observer` is removed as well, and the components + // that are using the query are not re-rendered. This is why we need to reset + // the queries, so that the `Observer` is re-created and the components are re-rendered. + // For more details: https://github.com/wasp-lang/wasp/pull/1014/files#r1111862125 + queryClient.resetQueries() + // If we don't remove the queries after invalidating them, the old query data + // remains in the cache, casuing a potential privacy issue. + queryClient.removeQueries() +} + +/** + * Invalidates all queries that are using specified resources. + * @param {string[]} resources - Names of resources. + */ +async function invalidateQueriesUsing(resources) { + const queryClient = await queryClientInitialized + + const queryCacheKeysToInvalidate = getQueriesUsingResources(resources) + queryCacheKeysToInvalidate.forEach( + queryCacheKey => queryClient.invalidateQueries(queryCacheKey) + ) +} + +/** + * @param {string} resource - Resource name. + * @returns {string[]} Array of "query cache keys" of queries that use specified resource. + */ +function getQueriesUsingResource(resource) { + return Array.from(resourceToQueryCacheKeys.get(resource) || []) +} + +function getQueriesUsingResources(resources) { + return Array.from(new Set(resources.flatMap(getQueriesUsingResource))) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/updateHandlersMap.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/updateHandlersMap.js new file mode 100644 index 0000000000..8c43c0b1ba --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/operations/updateHandlersMap.js @@ -0,0 +1,37 @@ +export function makeUpdateHandlersMap(calculateHash) { + const updateHandlers = new Map() + + function getHandlerTuples(queryKeyHash) { + return updateHandlers.get(queryKeyHash) || []; + } + + function add(queryKey, updateQuery) { + const queryKeyHash = calculateHash(queryKey) + const handlers = getHandlerTuples(queryKeyHash); + updateHandlers.set(queryKeyHash, [...handlers, { queryKey, updateQuery }]) + } + + function getUpdateHandlers(queryKey) { + const queryKeyHash = calculateHash(queryKey) + return getHandlerTuples(queryKeyHash).map(({ updateQuery }) => updateQuery) + } + + function remove(queryKeyToRemove) { + const queryKeyHash = calculateHash(queryKeyToRemove) + const filteredHandlers = getHandlerTuples(queryKeyHash).filter( + ({ queryKey }) => queryKey !== queryKeyToRemove + ) + + if (filteredHandlers.length > 0) { + updateHandlers.set(queryKeyHash, filteredHandlers) + } else { + updateHandlers.delete(queryKeyHash) + } + } + + return { + add, + remove, + getUpdateHandlers, + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/package.json b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/package.json new file mode 100644 index 0000000000..c8f32c097a --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/package.json @@ -0,0 +1,68 @@ +{ + "name": "wasp", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist" + }, + "exports": { + "./core/HttpError": "./dist/core/HttpError.js", + "./core/AuthError": "./dist/core/AuthError.js", + "./core/config": "./dist/core/config.js", + "./core/stitches.config": "./dist/core/stitches.config.js", + "./core/storage": "./dist/core/storage.js", + "./core/auth": "./dist/core/auth.js", + "./rpc": "./dist/rpc/index.js", + "./rpc/queries": "./dist/rpc/queries/index.js", + "./rpc/actions": "./dist/rpc/actions/index.js", + "./rpc/queryClient": "./dist/rpc/queryClient.js", + "./types": "./dist/types/index.js", + "./auth/login": "./dist/auth/login.js", + "./auth/logout": "./dist/auth/logout.js", + "./auth/user": "./dist/auth/user.js", + "./auth/session": "./dist/auth/session.js", + "./auth/utils": "./dist/auth/utils.js", + "./auth/forms/Login": "./dist/auth/forms/Login.jsx", + "./auth/forms/Signup": "./dist/auth/forms/Signup.jsx", + "./auth/pages/createAuthRequiredPage": "./dist/auth/pages/createAuthRequiredPage.jsx", + "./api": "./dist/api/index.js", + "./api/*": "./dist/api/*", + "./operations": "./dist/operations/index.js", + "./ext-src/*": "./dist/ext-src/*.js", + "./operations/*": "./dist/operations/*", + "./universal/url": "./dist/universal/url.js", + "./universal/types": "./dist/universal/types.js", + "./universal/validators": "./dist/universal/validators.js", + "./server/dbClient": "./dist/server/dbClient.js", + "./server/config": "./dist/server/config.js", + "./server/utils": "./dist/server/utils.js", + "./server/actions": "./dist/server/actions/index.js", + "./server/queries": "./dist/server/queries/index.js" + }, + "license": "ISC", + "include": [ + "src/**/*" + ], + "dependencies": {"@prisma/client": "4.16.2", + "prisma": "4.16.2", + "@tanstack/react-query": "^4.29.0", + "axios": "^1.4.0", + "express": "~4.18.1", + "jsonwebtoken": "^8.5.1", + "mitt": "3.0.0", + "react": "^18.2.0", + "lodash.merge": "^4.6.2", + "react-router-dom": "^5.3.3", + "react-hook-form": "^7.45.4", + "secure-password": "^4.0.0", + "superjson": "^1.12.2", + "@types/express-serve-static-core": "^4.17.13", + "@stitches/react": "^1.2.8", + "lucia": "^3.0.0-beta.14", + "@lucia-auth/adapter-prisma": "^4.0.0-beta.9" +}, + "devDependencies": {"@tsconfig/node18": "latest" +} +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/core.d.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/core.d.ts new file mode 100644 index 0000000000..ea41a0eed3 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/core.d.ts @@ -0,0 +1,13 @@ +import { type Action } from '.' +import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types' + +export function createAction( + actionRoute: string, + entitiesUsed: unknown[] +): ActionFor + +type ActionFor = Expand< + Action[0], _Awaited<_ReturnType>> +> + +type GenericBackendAction = (args: never, context: any) => unknown diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/core.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/core.js new file mode 100644 index 0000000000..cd1c60ecef --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/core.js @@ -0,0 +1,37 @@ +import { callOperation, makeOperationRoute } from 'wasp/operations' +import { + registerActionInProgress, + registerActionDone, +} from 'wasp/operations/resources' + +// todo(filip) - turn helpers and core into the same thing + +export function createAction(relativeActionRoute, entitiesUsed) { + const actionRoute = makeOperationRoute(relativeActionRoute) + + async function internalAction(args, specificOptimisticUpdateDefinitions) { + registerActionInProgress(specificOptimisticUpdateDefinitions) + try { + // The `return await` is not redundant here. If we removed the await, the + // `finally` block would execute before the action finishes, prematurely + // registering the action as done. + return await callOperation(actionRoute, args) + } finally { + await registerActionDone(entitiesUsed, specificOptimisticUpdateDefinitions) + } + } + + // We expose (and document) a restricted version of the API for our users, + // while also attaching the full "internal" API to the exposed action. By + // doing this, we can easily use the internal API of an action a users passes + // into our system (e.g., through the `useAction` hook) without needing a + // lookup table. + // + // While it does technically allow our users to access the interal API, it + // shouldn't be a problem in practice. Still, if it turns out to be a problem, + // we can always hide it using a Symbol. + const action = (args) => internalAction(args, []) + action.internal = internalAction + + return action +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/index.ts new file mode 100644 index 0000000000..2be33b3d65 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/actions/index.ts @@ -0,0 +1,14 @@ +import { createAction } from './core' +import { CreateTask, UpdateTask } from 'wasp/server/actions' + +export const updateTask = createAction('operations/update-task', [ + 'Task', +]) + +export const createTask = createAction('operations/create-task', [ + 'Task', +]) + +export const deleteTasks = createAction('operations/delete-tasks', [ + 'Task', +]) diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/index.ts new file mode 100644 index 0000000000..8a743e3456 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/index.ts @@ -0,0 +1,338 @@ +import { + QueryClient, + QueryKey, + useMutation, + UseMutationOptions, + useQueryClient, + useQuery as rqUseQuery, + UseQueryResult, +} from "@tanstack/react-query"; +export { configureQueryClient } from "./queryClient"; + +export type Query = { + (queryCacheKey: string[], args: Input): Promise; +}; + +export function useQuery( + queryFn: Query, + queryFnArgs?: Input, + options?: any +): UseQueryResult; + +export function useQuery(queryFn, queryFnArgs, options) { + if (typeof queryFn !== "function") { + throw new TypeError("useQuery requires queryFn to be a function."); + } + if (!queryFn.queryCacheKey) { + throw new TypeError( + "queryFn needs to have queryCacheKey property defined." + ); + } + + const queryKey = + queryFnArgs !== undefined + ? [...queryFn.queryCacheKey, queryFnArgs] + : queryFn.queryCacheKey; + return rqUseQuery({ + queryKey, + queryFn: () => queryFn(queryKey, queryFnArgs), + ...options, + }); +} + +// todo - turn helpers and core into the same thing + +export type Action = [Input] extends [never] + ? (args?: unknown) => Promise + : (args: Input) => Promise; + +/** + * An options object passed into the `useAction` hook and used to enhance the + * action with extra options. + * + */ +export type ActionOptions = { + optimisticUpdates: OptimisticUpdateDefinition[]; +}; + +/** + * A documented (public) way to define optimistic updates. + */ +export type OptimisticUpdateDefinition = { + getQuerySpecifier: GetQuerySpecifier; + updateQuery: UpdateQuery; +}; + +/** + * A function that takes an item and returns a Wasp Query specifier. + */ +export type GetQuerySpecifier = ( + item: ActionInput +) => QuerySpecifier; + +/** + * A function that takes an item and the previous state of the cache, and returns + * the desired (new) state of the cache. + */ +export type UpdateQuery = ( + item: ActionInput, + oldData: CachedData | undefined +) => CachedData; + +/** + * A public query specifier used for addressing Wasp queries. See our docs for details: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + */ +export type QuerySpecifier = [Query, ...any[]]; + +/** + * A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates). + * + * @param actionFn The Wasp Action you wish to enhance/decorate. + * @param actionOptions An options object for enhancing/decorating the given Action. + * @returns A decorated Action with added behavior but an unchanged API. + */ +export function useAction( + actionFn: Action, + actionOptions?: ActionOptions +): typeof actionFn { + const queryClient = useQueryClient(); + + let mutationFn = actionFn; + let options = {}; + if (actionOptions?.optimisticUpdates) { + const optimisticUpdatesDefinitions = actionOptions.optimisticUpdates.map( + translateToInternalDefinition + ); + mutationFn = makeOptimisticUpdateMutationFn( + actionFn, + optimisticUpdatesDefinitions + ); + options = makeRqOptimisticUpdateOptions( + queryClient, + optimisticUpdatesDefinitions + ); + } + + // NOTE: We decided to hide React Query's extra mutation features (e.g., + // isLoading, onSuccess and onError callbacks, synchronous mutate) and only + // expose a simple async function whose API matches the original Action. + // We did this to avoid cluttering the API with stuff we're not sure we need + // yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to + // clearly separate our opinionated API from React Query's lower-level + // advanced API (which users can also use) + const mutation = useMutation(mutationFn, options); + return (args) => mutation.mutateAsync(args); +} + +/** + * An internal (undocumented, private, desugared) way of defining optimistic updates. + */ +type InternalOptimisticUpdateDefinition = { + getQueryKey: (item: ActionInput) => QueryKey; + updateQuery: UpdateQuery; +}; + +/** + * An UpdateQuery function "instantiated" with a specific item. It only takes + * the current state of the cache and returns the desired (new) state of the + * cache. + */ +type SpecificUpdateQuery = (oldData: CachedData) => CachedData; + +/** + * A specific, "instantiated" optimistic update definition which contains a + * fully-constructed query key and a specific update function. + */ +type SpecificOptimisticUpdateDefinition = { + queryKey: QueryKey; + updateQuery: SpecificUpdateQuery; +}; + +type InternalAction = Action & { + internal( + item: Input, + optimisticUpdateDefinitions: SpecificOptimisticUpdateDefinition[] + ): Promise; +}; + +/** + * Translates/Desugars a public optimistic update definition object into a + * definition object our system uses internally. + * + * @param publicOptimisticUpdateDefinition An optimistic update definition + * object that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns An internally-used optimistic update definition object. + */ +function translateToInternalDefinition( + publicOptimisticUpdateDefinition: OptimisticUpdateDefinition +): InternalOptimisticUpdateDefinition { + const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition; + + const definitionErrors = []; + if (typeof getQuerySpecifier !== "function") { + definitionErrors.push("`getQuerySpecifier` is not a function."); + } + if (typeof updateQuery !== "function") { + definitionErrors.push("`updateQuery` is not a function."); + } + if (definitionErrors.length) { + throw new TypeError( + `Invalid optimistic update definition: ${definitionErrors.join(", ")}.` + ); + } + + return { + getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)), + updateQuery, + }; +} + +/** + * Creates a function that performs an action while telling it about the + * optimistic updates it caused. + * + * @param actionFn The Wasp Action. + * @param optimisticUpdateDefinitions The optimisitc updates the action causes. + * @returns An decorated action which performs optimistic updates. + */ +function makeOptimisticUpdateMutationFn( + actionFn: Action, + optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition< + Input, + CachedData + >[] +): typeof actionFn { + return function performActionWithOptimisticUpdates(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + (generalDefinition) => + getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ); + return (actionFn as InternalAction).internal( + item, + specificOptimisticUpdateDefinitions + ); + }; +} + +/** + * Given a ReactQuery query client and our internal definition of optimistic + * updates, this function constructs an object describing those same optimistic + * updates in a format we can pass into React Query's useMutation hook. In other + * words, it translates our optimistic updates definition into React Query's + * optimistic updates definition. Check their docs for details: + * https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates + * + * @param queryClient The QueryClient instance used by React Query. + * @param optimisticUpdateDefinitions A list containing internal optimistic + * updates definition objects (i.e., a list where each object carries the + * instructions for performing particular optimistic update). + * @returns An object containing 'onMutate' and 'onError' functions + * corresponding to the given optimistic update definitions (check the docs + * linked above for details). + */ +function makeRqOptimisticUpdateOptions( + queryClient: QueryClient, + optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition< + ActionInput, + CachedData + >[] +): Pick { + async function onMutate(item) { + const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map( + (generalDefinition) => + getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item) + ); + + // Cancel any outgoing refetches (so they don't overwrite our optimistic update). + // Theoretically, we can be a bit faster. Instead of awaiting the + // cancellation of all queries, we could cancel and update them in parallel. + // However, awaiting cancellation hasn't yet proven to be a performance bottleneck. + await Promise.all( + specificOptimisticUpdateDefinitions.map(({ queryKey }) => + queryClient.cancelQueries(queryKey) + ) + ); + + // We're using a Map to correctly serialize query keys that contain objects. + const previousData = new Map(); + specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => { + // Snapshot the currently cached value. + const previousDataForQuery: CachedData = + queryClient.getQueryData(queryKey); + + // Attempt to optimistically update the cache using the new value. + try { + queryClient.setQueryData(queryKey, updateQuery); + } catch (e) { + console.error( + "The `updateQuery` function threw an exception, skipping optimistic update:" + ); + console.error(e); + } + + // Remember the snapshotted value to restore in case of an error. + previousData.set(queryKey, previousDataForQuery); + }); + + return { previousData }; + } + + function onError(_err, _item, context) { + // All we do in case of an error is roll back all optimistic updates. We ensure + // not to do anything else because React Query rethrows the error. This allows + // the programmer to handle the error as they usually would (i.e., we want the + // error handling to work as it would if the programmer wasn't using optimistic + // updates). + context.previousData.forEach(async (data, queryKey) => { + await queryClient.cancelQueries(queryKey); + queryClient.setQueryData(queryKey, data); + }); + } + + return { + onMutate, + onError, + }; +} + +/** + * Constructs the definition for optimistically updating a specific item. It + * uses a closure over the updated item to construct an item-specific query key + * (e.g., useful when the query key depends on an ID). + * + * @param optimisticUpdateDefinition The general, "uninstantiated" optimistic + * update definition with a function for constructing the query key. + * @param item The item triggering the Action/optimistic update (i.e., the + * argument passed to the Action). + * @returns A specific optimistic update definition which corresponds to the + * provided definition and closes over the provided item. + */ +function getOptimisticUpdateDefinitionForSpecificItem( + optimisticUpdateDefinition: InternalOptimisticUpdateDefinition< + ActionInput, + CachedData + >, + item: ActionInput +): SpecificOptimisticUpdateDefinition { + const { getQueryKey, updateQuery } = optimisticUpdateDefinition; + return { + queryKey: getQueryKey(item), + updateQuery: (old) => updateQuery(item, old), + }; +} + +/** + * Translates a Wasp query specifier to a query cache key used by React Query. + * + * @param querySpecifier A query specifier that's a part of the public API: + * https://wasp-lang.dev/docs/language/features#the-useaction-hook. + * @returns A cache key React Query internally uses for addressing queries. + */ +function getRqQueryKeyFromSpecifier( + querySpecifier: QuerySpecifier +): QueryKey { + const [queryFn, ...otherKeys] = querySpecifier; + return [...(queryFn as any).queryCacheKey, ...otherKeys]; +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/core.d.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/core.d.ts new file mode 100644 index 0000000000..ddbb4f2b8e --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/core.d.ts @@ -0,0 +1,23 @@ +import { type Query } from '..' +import { Route } from 'wasp/types' +import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types' + +export function createQuery( + queryRoute: string, + entitiesUsed: any[] +): QueryFor + +export function addMetadataToQuery( + query: (...args: any[]) => Promise, + metadata: { + relativeQueryPath: string + queryRoute: Route + entitiesUsed: string[] + } +): void + +type QueryFor = Expand< + Query[0], _Awaited<_ReturnType>> +> + +type GenericBackendQuery = (args: never, context: any) => unknown diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/core.js b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/core.js new file mode 100644 index 0000000000..616fb82958 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/core.js @@ -0,0 +1,30 @@ +import { callOperation, makeOperationRoute } from 'wasp/operations' +import { + addResourcesUsedByQuery, + getActiveOptimisticUpdates, +} from 'wasp/operations/resources' + +export function createQuery(relativeQueryPath, entitiesUsed) { + const queryRoute = makeOperationRoute(relativeQueryPath) + + async function query(queryKey, queryArgs) { + const serverResult = await callOperation(queryRoute, queryArgs) + return getActiveOptimisticUpdates(queryKey).reduce( + (result, update) => update(result), + serverResult, + ) + } + + addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) + + return query +} + +export function addMetadataToQuery( + query, + { relativeQueryPath, queryRoute, entitiesUsed } +) { + query.queryCacheKey = [relativeQueryPath] + query.route = queryRoute + addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/index.ts new file mode 100644 index 0000000000..a03221553d --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queries/index.ts @@ -0,0 +1,6 @@ +import { createQuery } from './core' +import { GetTasks } from 'wasp/server/queries' + +export const getTasks = createQuery('operations/get-tasks', ['Task']) + +export { addMetadataToQuery } from './core' diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queryClient.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queryClient.ts new file mode 100644 index 0000000000..448be4c5ce --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/rpc/queryClient.ts @@ -0,0 +1,33 @@ +import { QueryClient } from "@tanstack/react-query"; + +type QueryClientConfig = object; + +const defaultQueryClientConfig = {}; + +let queryClientConfig: QueryClientConfig, + resolveQueryClientInitialized: (...args: any[]) => any, + isQueryClientInitialized: boolean; + +export const queryClientInitialized: Promise = new Promise( + (resolve) => { + resolveQueryClientInitialized = resolve; + } +); + +export function configureQueryClient(config: QueryClientConfig): void { + if (isQueryClientInitialized) { + throw new Error( + "Attempted to configure the QueryClient after initialization" + ); + } + + queryClientConfig = config; +} + +export function initializeQueryClient(): void { + const queryClient = new QueryClient( + queryClientConfig ?? defaultQueryClientConfig + ); + isQueryClientInitialized = true; + resolveQueryClientInitialized(queryClient); +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/index.ts new file mode 100644 index 0000000000..399cc1965b --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/index.ts @@ -0,0 +1,99 @@ +import { type Expand } from 'wasp/universal/types'; +import { type Request, type Response } from 'express' +import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core' +import prisma from "wasp/server/dbClient" +import { + type User, + type Auth, + type AuthIdentity, +} from "wasp/entities" +import { + type EmailProviderData, + type UsernameProviderData, + type OAuthProviderData, +} from 'wasp/auth/utils' +import { type _Entity } from "./taggedEntities" +import { type Payload } from "./serialization"; + +export * from "./taggedEntities" +export * from "./serialization" + +export type Query = + Operation + +export type Action = + Operation + +export type AuthenticatedQuery = + AuthenticatedOperation + +export type AuthenticatedAction = + AuthenticatedOperation + +type AuthenticatedOperation = ( + args: Input, + context: ContextWithUser, +) => Output | Promise + +export type AuthenticatedApi< + Entities extends _Entity[], + Params extends ExpressParams, + ResBody, + ReqBody, + ReqQuery extends ExpressQuery, + Locals extends Record +> = ( + req: Request, + res: Response, + context: ContextWithUser, +) => void + +type Operation = ( + args: Input, + context: Context, +) => Output | Promise + +export type Api< + Entities extends _Entity[], + Params extends ExpressParams, + ResBody, + ReqBody, + ReqQuery extends ExpressQuery, + Locals extends Record +> = ( + req: Request, + res: Response, + context: Context, +) => void + +type EntityMap = { + [EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName] +} + +export type PrismaDelegate = { + "User": typeof prisma.user, + "Task": typeof prisma.task, +} + +type Context = Expand<{ + entities: Expand> +}> + +type ContextWithUser = Expand & { user?: SanitizedUser }> + +// TODO: This type must match the logic in core/session.js (if we remove the +// password field from the object there, we must do the same here). Ideally, +// these two things would live in the same place: +// https://github.com/wasp-lang/wasp/issues/965 + +export type DeserializedAuthIdentity = Expand & { + providerData: Omit | Omit | OAuthProviderData +}> + +export type SanitizedUser = User & { + auth: Auth & { + identities: DeserializedAuthIdentity[] + } | null +} + +export type { ProviderName } from 'wasp/auth/utils' diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/serialization.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/serialization.ts new file mode 100644 index 0000000000..595b5ba69f --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/serialization.ts @@ -0,0 +1,43 @@ +export type Payload = void | SuperJSONValue + +// The part below was copied from SuperJSON and slightly modified: +// https://github.com/blitz-js/superjson/blob/ae7dbcefe5d3ece5b04be0c6afe6b40f3a44a22a/src/types.ts +// +// We couldn't use SuperJSON's types directly because: +// 1. They aren't exported publicly. +// 2. They have a werid quirk that turns `SuperJSONValue` into `any`. +// See why here: +// https://github.com/blitz-js/superjson/pull/36#issuecomment-669239876 +// +// We changed the code as little as possible to make future comparisons easier. +export type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject + +export interface JSONObject { + [key: string]: JSONValue +} + +type PrimitiveJSONValue = string | number | boolean | undefined | null + +interface JSONArray extends Array {} + +type SerializableJSONValue = + | Symbol + | Set + | Map + | undefined + | bigint + | Date + | RegExp + +// Here's where we excluded `ClassInstance` (which was `any`) from the union. +type SuperJSONValue = + | JSONValue + | SerializableJSONValue + | SuperJSONArray + | SuperJSONObject + +interface SuperJSONArray extends Array {} + +interface SuperJSONObject { + [key: string]: SuperJSONValue +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts new file mode 100644 index 0000000000..c82affed3b --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts @@ -0,0 +1,22 @@ +// Wasp internally uses the types defined in this file for typing entity maps in +// operation contexts. +// +// We must explicitly tag all entities with their name to avoid issues with +// structural typing. See https://github.com/wasp-lang/wasp/pull/982 for details. +import { + type Entity, + type EntityName, + type User, + type Task, +} from 'wasp/entities' + +export type _User = WithName +export type _Task = WithName + +export type _Entity = + | _User + | _Task + | never + +type WithName = + E & { _entityName: Name } diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/index.ts new file mode 100644 index 0000000000..9cafc77c30 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/index.ts @@ -0,0 +1,38 @@ +import prisma from 'wasp/server/dbClient' + +import { createTask as createTask_ext } from 'wasp/ext-src/task/actions' +import { updateTask as updateTask_ext } from 'wasp/ext-src/task/actions' +import { deleteTasks as deleteTasks_ext } from 'wasp/ext-src/task/actions' + +export type CreateTask = typeof createTask_ext + +export const createTask = async (args, context) => { + return (createTask_ext as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} + +export type UpdateTask = typeof updateTask_ext + +export const updateTask = async (args, context) => { + return (updateTask_ext as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} + +export type DeleteTasks = typeof deleteTasks_ext + +export const deleteTasks = async (args, context) => { + return (deleteTasks_ext as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/types.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/types.ts new file mode 100644 index 0000000000..11f743b28d --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/actions/types.ts @@ -0,0 +1,33 @@ +import { + type _Task, + type AuthenticatedAction, + type Payload, +} from 'wasp/server/_types' + +export type CreateTask = + AuthenticatedAction< + [ + _Task, + ], + Input, + Output + > + +export type UpdateTask = + AuthenticatedAction< + [ + _Task, + ], + Input, + Output + > + +export type DeleteTasks = + AuthenticatedAction< + [ + _Task, + ], + Input, + Output + > + diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/index.ts new file mode 100644 index 0000000000..7325cc4fea --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/index.ts @@ -0,0 +1,14 @@ +import prisma from 'wasp/server/dbClient' + +import { getTasks as getTasks_ext } from 'wasp/ext-src/task/queries' + +export type GetTasks = typeof getTasks_ext + +export const getTasks = async (args, context) => { + return (getTasks_ext as any)(args, { + ...context, + entities: { + Task: prisma.task, + }, + }) +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/types.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/types.ts new file mode 100644 index 0000000000..f7391d0da2 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/queries/types.ts @@ -0,0 +1,16 @@ + +import { + type _Task, + type AuthenticatedQuery, + type Payload, +} from 'wasp/server/_types' + +export type GetTasks = + AuthenticatedQuery< + [ + _Task, + ], + Input, + Output + > + diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/utils.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/utils.ts new file mode 100644 index 0000000000..df3052cd57 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/server/utils.ts @@ -0,0 +1,66 @@ +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + +import { type SanitizedUser } from 'wasp/server/_types/index.js' + +type RequestWithExtraFields = Request & { + user?: SanitizedUser +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/types/index.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/types/index.ts new file mode 100644 index 0000000000..982b766e37 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/types/index.ts @@ -0,0 +1,9 @@ +// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs). +export enum HttpMethod { + Get = 'GET', + Post = 'POST', + Put = 'PUT', + Delete = 'DELETE', +} + +export type Route = { method: HttpMethod; path: string } diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/universal/types.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/universal/types.ts new file mode 100644 index 0000000000..8cadbd740d --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/universal/types.ts @@ -0,0 +1,31 @@ +// This is a helper type used exclusively for DX purposes. It's a No-op for the +// compiler, but expands the type's representatoin in IDEs (i.e., inlines all +// type constructors) to make it more readable for the user. +// +// It expands this SO answer to functions: https://stackoverflow.com/a/57683652 +export type Expand = T extends (...args: infer A) => infer R + ? (...args: A) => R + : T extends infer O + ? { [K in keyof O]: O[K] } + : never + +// TypeScript's native Awaited type exhibits strange behavior in VS Code (see +// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for +// details). Until it's fixed, we're using our own type for this. +// +// TODO: investigate further. This most likely has something to do with an +// unsatisfied 'extends' constraints. A mismatch is probably happening with +// function parameter types and/or return types (check '_ReturnType' below for +// more). +export type _Awaited = T extends Promise + ? _Awaited + : T + +// TypeScript's native ReturnType does not work for functions of type '(...args: +// never[]) => unknown' (and that's what operations currently use). +// +// TODO: investigate how to properly specify the 'extends' constraint for function +// type (i.e., any vs never and unknown) and stick with that. Take DX into +// consideration. +export type _ReturnType unknown> = + T extends (...args: never[]) => infer R ? R : never diff --git a/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/universal/url.ts b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/universal/url.ts new file mode 100644 index 0000000000..d21c06c65c --- /dev/null +++ b/waspc/examples/todo-typescript/.wasp/out/sdk/wasp/universal/url.ts @@ -0,0 +1,3 @@ +export function stripTrailingSlash(url?: string): string | undefined { + return url?.replace(/\/$/, ""); +} diff --git a/waspc/examples/todo-typescript/.wasproot b/waspc/examples/todo-typescript/.wasproot new file mode 100644 index 0000000000..ca2cfdb482 --- /dev/null +++ b/waspc/examples/todo-typescript/.wasproot @@ -0,0 +1 @@ +File marking the root of Wasp project. diff --git a/waspc/examples/todo-typescript/cleanstart b/waspc/examples/todo-typescript/cleanstart new file mode 100755 index 0000000000..e32b3aecac --- /dev/null +++ b/waspc/examples/todo-typescript/cleanstart @@ -0,0 +1,3 @@ +#!/bin/bash +cabal run wasp-cli clean; cabal run wasp-cli db migrate-dev && ./fix; cabal run wasp-cli start + diff --git a/waspc/examples/todo-typescript/fix b/waspc/examples/todo-typescript/fix new file mode 100755 index 0000000000..4943774959 --- /dev/null +++ b/waspc/examples/todo-typescript/fix @@ -0,0 +1 @@ +rm -r .wasp/out/web-app/node_modules/{react,.vite} diff --git a/waspc/examples/todo-typescript/main.wasp b/waspc/examples/todo-typescript/main.wasp new file mode 100644 index 0000000000..21d03e54dc --- /dev/null +++ b/waspc/examples/todo-typescript/main.wasp @@ -0,0 +1,75 @@ +app TodoTypescript { + wasp: { + version: "^0.12.0" + }, + title: "ToDo TypeScript", + + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, // this is a very naive implementation, use 'email' in production instead + //google: {}, //https://wasp-lang.dev/docs/integrations/google + //gitHub: {}, //https://wasp-lang.dev/docs/integrations/github + //email: {} //https://wasp-lang.dev/docs/guides/email-auth + }, + onAuthFailedRedirectTo: "/login", + } +} + +// Use Prisma Schema Language (PSL) to define our entities: https://www.prisma.io/docs/concepts/components/prisma-schema +// Run `wasp db migrate-dev` in the CLI to create the database tables +// Then run `wasp db studio` to open Prisma Studio and view your db models +entity User {=psl + id Int @id @default(autoincrement()) + tasks Task[] +psl=} + +entity Task {=psl + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) + user User @relation(fields: [userId], references: [id]) + userId Int +psl=} + +route RootRoute { path: "/", to: MainPage } +page MainPage { + authRequired: true, + // todo(filip): LSP features are broken beucase I haven't yet updated LSP to the new structure. + component: import { MainPage } from "@src/MainPage.tsx" +} + +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { LoginPage } from "@src/user/LoginPage.tsx" +} + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { SignupPage } from "@src/user/SignupPage.tsx" +} + +query getTasks { + // We specify the JS implementation of our query (which is an async JS function) + // Even if you use TS and have a queries.ts file, you will still need to import it using the .js extension. + // see here for more info: https://wasp-lang.dev/docs/tutorials/todo-app/03-listing-tasks#wasp-declaration + fn: import { getTasks } from "@src/task/queries", + // We tell Wasp that this query is doing something with the `Task` entity. With that, Wasp will + // automatically refresh the results of this query when tasks change. + entities: [Task] +} + +action createTask { + fn: import { createTask } from "@src/task/actions", + entities: [Task] +} + +action updateTask { + fn: import { updateTask } from "@src/task/actions", + entities: [Task] +} + +action deleteTasks { + fn: import { deleteTasks } from "@src/task/actions", + entities: [Task], +} diff --git a/waspc/examples/todo-typescript/migrate b/waspc/examples/todo-typescript/migrate new file mode 100755 index 0000000000..3c41785806 --- /dev/null +++ b/waspc/examples/todo-typescript/migrate @@ -0,0 +1,6 @@ +rsync -a .wasp/out/web-app/node_modules/ node_modules/ +rsync -a .wasp/out/server/node_modules/ node_modules/ +# rsync -a node_modules_wasp/ node_modules +cabal run wasp-cli db migrate-dev +find .wasp/out/server/node_modules -mindepth 1 -type d | grep -Eiv 'prisma|\.bin' | xargs rm -r 2> /dev/null +rm -r .wasp/out/web-app/node_modules diff --git a/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql b/waspc/examples/todo-typescript/migrations/20240121113923_init/migration.sql similarity index 72% rename from examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql rename to waspc/examples/todo-typescript/migrations/20240121113923_init/migration.sql index 0ea8e16da6..919941fb15 100644 --- a/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql +++ b/waspc/examples/todo-typescript/migrations/20240121113923_init/migration.sql @@ -30,5 +30,19 @@ CREATE TABLE "AuthIdentity" ( CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "expiresAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + -- CreateIndex CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); diff --git a/examples/todo-typescript/migrations/migration_lock.toml b/waspc/examples/todo-typescript/migrations/migration_lock.toml similarity index 100% rename from examples/todo-typescript/migrations/migration_lock.toml rename to waspc/examples/todo-typescript/migrations/migration_lock.toml diff --git a/waspc/examples/todo-typescript/package-lock.json b/waspc/examples/todo-typescript/package-lock.json new file mode 100644 index 0000000000..442c0f7aae --- /dev/null +++ b/waspc/examples/todo-typescript/package-lock.json @@ -0,0 +1,9783 @@ +{ + "name": "prototype", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prototype", + "workspaces": [ + ".wasp/out/sdk/wasp", + ".wasp/out/web-app", + ".wasp/out/server" + ], + "dependencies": { + "react": "^18.2.0", + "wasp": "file:.wasp/out/sdk/wasp" + }, + "devDependencies": { + "@types/react": "^18.0.37", + "prisma": "4.16.2", + "typescript": "^5.1.0", + "vite": "^4.3.9" + } + }, + ".wasp/out/sdk/wasp": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@lucia-auth/adapter-prisma": "^4.0.0-beta.9", + "@prisma/client": "4.16.2", + "@stitches/react": "^1.2.8", + "@tanstack/react-query": "^4.29.0", + "@types/express-serve-static-core": "^4.17.13", + "axios": "^1.4.0", + "express": "~4.18.1", + "jsonwebtoken": "^8.5.1", + "lodash.merge": "^4.6.2", + "lucia": "^3.0.0-beta.14", + "mitt": "3.0.0", + "prisma": "4.16.2", + "react": "^18.2.0", + "react-hook-form": "^7.45.4", + "react-router-dom": "^5.3.3", + "secure-password": "^4.0.0", + "superjson": "^1.12.2" + }, + "devDependencies": { + "@tsconfig/node18": "latest" + } + }, + ".wasp/out/server": { + "version": "0.0.0", + "hasInstallScript": true, + "dependencies": { + "cookie-parser": "~1.4.6", + "cors": "^2.8.5", + "dotenv": "16.0.2", + "express": "~4.18.1", + "helmet": "^6.0.0", + "jsonwebtoken": "^8.5.1", + "morgan": "~1.10.0", + "patch-package": "^6.4.7", + "rate-limiter-flexible": "^2.4.1", + "secure-password": "^4.0.0", + "superjson": "^1.12.2", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@tsconfig/node18": "latest", + "@types/cors": "^2.8.5", + "@types/express": "^4.17.13", + "@types/express-serve-static-core": "^4.17.13", + "@types/node": "^18.0.0", + "@types/uuid": "^9.0.0", + "nodemon": "^2.0.19", + "standard": "^17.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + ".wasp/out/web-app": { + "name": "TodoTypescript", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^4.29.0", + "axios": "^1.4.0", + "mitt": "3.0.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", + "react-router-dom": "^5.3.3", + "superjson": "^1.12.2" + }, + "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^14.0.0", + "@tsconfig/vite-react": "^2.0.0", + "@types/react-dom": "^18.0.11", + "@types/react-router-dom": "^5.3.3", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/ui": "^0.29.3", + "dotenv": "^16.0.3", + "jsdom": "^21.1.1", + "msw": "^1.1.0", + "vitest": "^0.29.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + ".wasp/out/web-app/node_modules/dotenv": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.2.tgz", + "integrity": "sha512-HTlk5nmhkm8F6JcdXvHIzaorzCoziNQT9mGxLPVXW8wJF1TiGSL60ZGB4gHWabHOaMmWmhvk2/lPHfnBiT78AQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", + "dev": true + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", + "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", + "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.23.3.tgz", + "integrity": "sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.23.3.tgz", + "integrity": "sha512-91RS0MDnAWDNvGC6Wio5XYkyWI39FMFO+JK9+4AlgaTH+yWwVTsw7/sn6LK0lH7c5F+TFkpv/3LfCJ1Ydwof/g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.23.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", + "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", + "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-0.45.0.tgz", + "integrity": "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", + "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "dev": true + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucia-auth/adapter-prisma": { + "version": "4.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@lucia-auth/adapter-prisma/-/adapter-prisma-4.0.0-beta.9.tgz", + "integrity": "sha512-Pht/eDOGdwyMuhp/pm6ttSixW74Rt6Sl3B2fZK3e7tWqMBPR4LbpmQ6sn6QJlBFHlsBecNTPdtFF9CBaAlR+Tg==", + "peerDependencies": { + "@prisma/client": "^4.2.0 || ^5.0.0", + "lucia": "3.0.0-beta.14" + } + }, + "node_modules/@mswjs/cookies": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.2.2.tgz", + "integrity": "sha512-mlN83YSrcFgk7Dm1Mys40DLssI1KdJji2CMKN8eOlBqsTADYzj2+jWzsANsUTFbxDMWPD5e9bfA1RGqBpS3O1g==", + "dev": true, + "dependencies": { + "@types/set-cookie-parser": "^2.4.0", + "set-cookie-parser": "^2.4.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.17.10", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.17.10.tgz", + "integrity": "sha512-N8x7eSLGcmUFNWZRxT1vsHvypzIRgQYdG0rJey/rZCy6zT/30qDt8Joj7FxzGNLSwXbeZqJOMqDurp7ra4hgbw==", + "dev": true, + "dependencies": { + "@open-draft/until": "^1.0.3", + "@types/debug": "^4.1.7", + "@xmldom/xmldom": "^0.8.3", + "debug": "^4.3.3", + "headers-polyfill": "3.2.5", + "outvariant": "^1.2.1", + "strict-event-emitter": "^0.2.4", + "web-encoding": "^1.1.5" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@mswjs/interceptors/node_modules/strict-event-emitter": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz", + "integrity": "sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A==", + "dev": true, + "dependencies": { + "events": "^3.3.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.1.1.tgz", + "integrity": "sha512-ATj9ua659JgrkICjJscaeZdmPr44cb/KFjNWuD0N6pux0SpzaM7+iOuuK11mAnQM2N9q0DT4REu6NkL8ZEhopw==", + "optional": true, + "dependencies": { + "@emnapi/core": "^0.45.0", + "@emnapi/runtime": "^0.45.0", + "@tybys/wasm-util": "^0.8.1" + } + }, + "node_modules/@node-rs/argon2": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-1.7.2.tgz", + "integrity": "sha512-+H6pc3M1vIX9YnG59YW7prHhhpv19P8YyxlXHnnFzTimf2q+kKDF7mGWbhvN9STqIY+P70Patn0Q6qb6Ib5/4g==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "1.7.2", + "@node-rs/argon2-android-arm64": "1.7.2", + "@node-rs/argon2-darwin-arm64": "1.7.2", + "@node-rs/argon2-darwin-x64": "1.7.2", + "@node-rs/argon2-freebsd-x64": "1.7.2", + "@node-rs/argon2-linux-arm-gnueabihf": "1.7.2", + "@node-rs/argon2-linux-arm64-gnu": "1.7.2", + "@node-rs/argon2-linux-arm64-musl": "1.7.2", + "@node-rs/argon2-linux-x64-gnu": "1.7.2", + "@node-rs/argon2-linux-x64-musl": "1.7.2", + "@node-rs/argon2-wasm32-wasi": "1.7.2", + "@node-rs/argon2-win32-arm64-msvc": "1.7.2", + "@node-rs/argon2-win32-ia32-msvc": "1.7.2", + "@node-rs/argon2-win32-x64-msvc": "1.7.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-1.7.2.tgz", + "integrity": "sha512-WhW84XOzdR4AOGc4BJvIg5lCRVBL0pXp/PPCe8QCyWw493p7VdNCdYpr2xdtjS/0zImmY85HNB/6zpzjLRTT/A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-1.7.2.tgz", + "integrity": "sha512-CdtayHSMIyDuVhSYFirwA757c4foQuyTjpysgFJLHweP9C7uDiBf9WBYij+UyabpaCadJ0wPyK6Vakinvlk4/g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-1.7.2.tgz", + "integrity": "sha512-hUOhtgYHTEyzX5sgMZVdXunONOus2HWpWydF5D/RYJ1mZ76FXRnFpQE40DqbzisdPIraKdn40m7JqkPP7wqdyg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-1.7.2.tgz", + "integrity": "sha512-lfs5HX+t542yUfcv6Aa/NeGD1nUCwyQNgnPEGcik71Ow6V13hkR1bHgmT1u3CHN4fBts0gW+DQEDsq1xlVgkvw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-1.7.2.tgz", + "integrity": "sha512-ROoF+4VaCBJUjddrTN1hjuqSl89ppRcjVXJscSPJjWzTlbzFmGGovJvIzUBmCr/Oq3yM1zKHj6MP9oRD5cB+/g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-1.7.2.tgz", + "integrity": "sha512-CBSB8KPI8LS74Bcz3dYaa2/khULutz4vSDvFWUERlSLX+mPdDhoZi6UPuUPPF9e01w8AbiK1YCqlLUTm3tIMfw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-1.7.2.tgz", + "integrity": "sha512-6LBTug6ZiWFakP3X3Nqs7ZTM03gmcSWX4YvEn20HhhQE5NDrsrw3zNqGj0cJiNzKKIMSDDuj7uGy+ITEfNo4CA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-1.7.2.tgz", + "integrity": "sha512-KjhQ+ZPne29t9VRVeIif7JdKwQba+tM6CBNYBoJB1iON0CUKeqSQtZcHuTj9gkf2SNRG5bsU4ABcfxd0OKsKHg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-1.7.2.tgz", + "integrity": "sha512-BQvp+iLtKqomHz4q5t1aKoni9osgvUDU5sZtHAlFm5dRTlGHnympcQVATRE5GHyH9C6MIM9W7P1kqEeCLGPolQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-1.7.2.tgz", + "integrity": "sha512-yXJudpBZQ98g+lWaHn9EzZ5KsAyqRdlpub/K+5NP7gHehb8wzBRIFAejIHAG0fvzQEEc86VOnV2koWIVZxWAvw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-wasm32-wasi": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-1.7.2.tgz", + "integrity": "sha512-diXlVjJZY2GIV8ZDwUqXPhacXsFR0klGSv5D9f+XidwWXK4udtzDhkM/7N/Mb7h1HAWaxZ6IN9spYFjvWH1wqg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-1.7.2.tgz", + "integrity": "sha512-dhIBrY04P9nbmwzBpgERQDmmSu4YBZyeEE32t4TikMz5rQ07iaVC+JpGmtCBZoDIsLDHGC8cikENd3YEqpqIcA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-1.7.2.tgz", + "integrity": "sha512-o1tfqr8gyALCzuxBoQfvhxkeYMaw/0H8Gmt7klTYyEIBvEFu7SD5qytXO9Px7t5420nZL/Wy5cflg3IB1s57Pg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-1.7.2.tgz", + "integrity": "sha512-v0h53XUc7hNgWiWi0qcMcHvj9/kwuItI9NwLK4C+gtzT3UB0cedhfIL8HFMKThMXasy41ZdbpCF2Bi0kJoLNEg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.9.2.tgz", + "integrity": "sha512-FKUo9iCSIti+ldwoOlY1ztyIFhZxEgT7jZ/UCt/9bg1rLmNdbQQD2JKIMImDCqmTWuLPY4ZF4Q5MyOMIfDCd8Q==", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/bcrypt-android-arm-eabi": "1.9.2", + "@node-rs/bcrypt-android-arm64": "1.9.2", + "@node-rs/bcrypt-darwin-arm64": "1.9.2", + "@node-rs/bcrypt-darwin-x64": "1.9.2", + "@node-rs/bcrypt-freebsd-x64": "1.9.2", + "@node-rs/bcrypt-linux-arm-gnueabihf": "1.9.2", + "@node-rs/bcrypt-linux-arm64-gnu": "1.9.2", + "@node-rs/bcrypt-linux-arm64-musl": "1.9.2", + "@node-rs/bcrypt-linux-x64-gnu": "1.9.2", + "@node-rs/bcrypt-linux-x64-musl": "1.9.2", + "@node-rs/bcrypt-wasm32-wasi": "1.9.2", + "@node-rs/bcrypt-win32-arm64-msvc": "1.9.2", + "@node-rs/bcrypt-win32-ia32-msvc": "1.9.2", + "@node-rs/bcrypt-win32-x64-msvc": "1.9.2" + } + }, + "node_modules/@node-rs/bcrypt-android-arm-eabi": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.9.2.tgz", + "integrity": "sha512-er/Q2khwpan9pczvTTqY/DJE4UU65u31xd0NkZlHUTKyB7djRhWfzoGexGx2GN+k831/RR3U8kKE/8QUHeO3hQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-android-arm64": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.9.2.tgz", + "integrity": "sha512-OUYatOEG5vbLbF73q2TC8UqrDO81zUQxnaFD/OAB1hcm6J+ur0zJ8E53c35/DIqkTp7JarPMraC4rouJ2ugN4w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-arm64": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.9.2.tgz", + "integrity": "sha512-svJKsGbzMAxOB5oluOYneN4YkKUy26WSMgm3KOIhgoX30IeMilj+2jFN/5qrI0oDZ0Iczb3XyL5DuZFtEkdP8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-x64": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.9.2.tgz", + "integrity": "sha512-9OrySjBi/rWix8NZWD/TrNbNcwMY0pAiMHdL09aJnJ07uPih83GGh1pq4UHCYFCMy7iTX8swOmDlGBUImkOZbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-freebsd-x64": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.9.2.tgz", + "integrity": "sha512-/djXV71RO6g5L1mI2pVvmp3x3pH7G4uKI3ODG1JBIXoz334oOcCMh40sB0uq0ljP8WEadker01p4T1rJE98fpg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.9.2.tgz", + "integrity": "sha512-F7wP950OTAooxEleUN4I2hqryGZK7hi1cSgRF13Wvbc597RFux35KiSxIXUA3mNt2DE7lV2PeceEtCOScaThWQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-gnu": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.9.2.tgz", + "integrity": "sha512-MehG+yQ0TgKMgKR1rO4hdvHkVsTM91Cof8qI9EJlS5+7+QSwfFA5O0zGwCkISD7bsyauJ5uJgcByGjpEobAHOg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-musl": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.9.2.tgz", + "integrity": "sha512-PRZTAJjOwKEGsIhmBvfNh81So+wGl4QyCFAt23j+KwBujLStjC0N3YaqtTlWVKG9tcriPtmMYiAQtXWIyIgg/w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-gnu": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.9.2.tgz", + "integrity": "sha512-5WfGO+O1m7nJ55WZ8XDq+ItA98Z4O7sNWsR+1nIj9YGT+Tx5zkQ2RBhpK6oCWZMluuZ0eKQ0FDmyP6K+2NDRIA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-musl": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.9.2.tgz", + "integrity": "sha512-VjCn0388p6PMCVUYHgYmHZrKNc7WwNJRr2WLJsHbQRGDOKbpNL6YolCjQxUchcSPDhzwrq1cIdy4j0fpoXEsdw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-wasm32-wasi": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.9.2.tgz", + "integrity": "sha512-P06aHfMzm9makwU+nM7WA65yQnS1xuqJ8l/6I/LvXjnl+lfB3DtJ2B0CSLtjnUGpUgcHbWl5gEbNnTPxSAirjQ==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/bcrypt-win32-arm64-msvc": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.9.2.tgz", + "integrity": "sha512-Iyo/Q5/eNw27VRd3mLBgh1b9b5fnT3QHTVwxv3Siv/MRAIfJXH/cTOe18qSwYQzNh0ZioW4yemFPYCWSZi7szA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-ia32-msvc": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.9.2.tgz", + "integrity": "sha512-6LHWMaPylyyHoS5863YpxAACVB8DWCxro5W6pQ4h8WKSgHpJp8Um9jphTdN0A2w45HZjUnfcFuiFFC+TbftjCw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-x64-msvc": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.9.2.tgz", + "integrity": "sha512-vZ9T1MOaYkLO9FTyl28YX0SYJneiYTKNFgM8PUv8nas8xrD+7OzokA0fEtlNp6413T7IKSD/iG9qi8nTWsiyGg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/until": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", + "integrity": "sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==", + "dev": true + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.24", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", + "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", + "dev": true + }, + "node_modules/@prisma/client": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.16.2.tgz", + "integrity": "sha512-qCoEyxv1ZrQ4bKy39GnylE8Zq31IRmm8bNhNbZx7bF2cU5aiCCnSa93J2imF88MBjn7J9eUQneNxUQVJdl/rPQ==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines-version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81" + }, + "engines": { + "node": ">=14.17" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/engines": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.16.2.tgz", + "integrity": "sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==", + "hasInstallScript": true + }, + "node_modules/@prisma/engines-version": { + "version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz", + "integrity": "sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@stitches/react": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz", + "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==", + "peerDependencies": { + "react": ">= 16.3.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.36.1.tgz", + "integrity": "sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz", + "integrity": "sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==", + "dependencies": { + "@tanstack/query-core": "4.36.1", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.1.2.tgz", + "integrity": "sha512-z4p7DVBTPjKM5qDZ0t5ZjzkpSNb+fZy1u6bzO7kk8oeGagpPCAtgh4cx1syrfp7a+QWkM021jGqjJaxJJnXAZg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tsconfig/node18": { + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.2.tgz", + "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==", + "dev": true + }, + "node_modules/@tsconfig/vite-react": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/vite-react/-/vite-react-2.0.1.tgz", + "integrity": "sha512-Sxt6vfbhgEzV2TH/02wLEiM7RJVYGx1mn2INlzovXS6/Fnm7G+CyOHZUD5c5fOlQvH54LNhdGgfI/9fuK6+kuQ==", + "dev": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.8.1.tgz", + "integrity": "sha512-GSsTwyBl4pIzsxAY5wroZdyQKyhXk0d8PCRZtrSZ2WEB1cBdrp2EgGBwHOGCZtIIPun/DL3+AykCv+J6fyRH4Q==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", + "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.11.tgz", + "integrity": "sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==", + "dev": true + }, + "node_modules/@types/chai-subset": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz", + "integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.41", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", + "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.11", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz", + "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/@types/js-levenshtein": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.3.tgz", + "integrity": "sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "dev": true + }, + "node_modules/@types/node": { + "version": "18.19.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.8.tgz", + "integrity": "sha512-g1pZtPhsvGVTwmeVoexWZLTQaOvXwoSq//pTL0DHeNzUDrFnir4fgETdhjhIxjVnN+hKOuh98+E1eMLnUXstFg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "dev": true + }, + "node_modules/@types/qs": { + "version": "6.9.11", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", + "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "node_modules/@types/react": { + "version": "18.2.48", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", + "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.18", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz", + "integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", + "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.7.tgz", + "integrity": "sha512-+ge/loa0oTozxip6zmhRIk8Z/boU51wl9Q6QdLZcokIGMzY5lFXYy/x7Htj2HTC6/KZP1hUbZ1ekx8DYXICvWg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.9", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", + "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, + "dependencies": { + "@types/jest": "*" + } + }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", + "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.5", + "@babel/plugin-transform-react-jsx-self": "^7.23.3", + "@babel/plugin-transform-react-jsx-source": "^7.23.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "0.29.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.29.8.tgz", + "integrity": "sha512-xlcVXn5I5oTq6NiZSY3ykyWixBxr5mG8HYtjvpgg6KaqHm0mvhX18xuwl5YGxIRNt/A5jidd7CWcNHrSvgaQqQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "0.29.8", + "@vitest/utils": "0.29.8", + "chai": "^4.3.7" + } + }, + "node_modules/@vitest/runner": { + "version": "0.29.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.29.8.tgz", + "integrity": "sha512-FzdhnRDwEr/A3Oo1jtIk/B952BBvP32n1ObMEb23oEJNO+qO5cBet6M2XWIDQmA7BDKGKvmhUf2naXyp/2JEwQ==", + "dev": true, + "dependencies": { + "@vitest/utils": "0.29.8", + "p-limit": "^4.0.0", + "pathe": "^1.1.0" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/spy": { + "version": "0.29.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.29.8.tgz", + "integrity": "sha512-VdjBe9w34vOMl5I5mYEzNX8inTxrZ+tYUVk9jxaZJmHFwmDFC/GV3KBFTA/JKswr3XHvZL+FE/yq5EVhb6pSAw==", + "dev": true, + "dependencies": { + "tinyspy": "^1.0.2" + } + }, + "node_modules/@vitest/ui": { + "version": "0.29.8", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.29.8.tgz", + "integrity": "sha512-+vbLd+c1R/XUWfzJsWeyjeiw13fwJ95I5tguxaqXRg61y9iYUKesVljg7Pttp2uo7VK+kAjvY91J41NZ1Vx3vg==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.12", + "flatted": "^3.2.7", + "pathe": "^1.1.0", + "picocolors": "^1.0.0", + "sirv": "^2.0.2" + } + }, + "node_modules/@vitest/utils": { + "version": "0.29.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.29.8.tgz", + "integrity": "sha512-qGzuf3vrTbnoY+RjjVVIBYfuWMjn3UMUqyQtdGNZ6ZIIyte7B37exj6LaVkrZiUTvzSadVvO/tJm8AEgbGCBPg==", + "dev": true, + "dependencies": { + "cli-truncate": "^3.1.0", + "diff": "^5.1.0", + "loupe": "^2.3.6", + "pretty-format": "^27.5.1" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "dev": true, + "optional": true + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/array-includes": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz", + "integrity": "sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "dependencies": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/builtins": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", + "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", + "dev": true, + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/builtins/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/builtins/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/builtins/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001579", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", + "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dotenv": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.2.tgz", + "integrity": "sha512-JvpYKUmzQhYoIFgK2MOnF3bciIZoItIIoryihy0rIA+H4Jy0FmgyKYAHCTN98P5ybGSJcIFbh6QKeJdtZd1qhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/electron-to-chromium": { + "version": "1.4.640", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.640.tgz", + "integrity": "sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.56.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-standard": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-promise": "^6.0.0" + } + }, + "node_modules/eslint-config-standard-jsx": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard-jsx/-/eslint-config-standard-jsx-11.0.0.tgz", + "integrity": "sha512-+1EV/R0JxEK1L0NGolAr8Iktm3Rgotx3BKwgaX+eAuSX8D952LULKtjgZD3F+e6SvibONnhLwoTi9DPxN5LvvQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peerDependencies": { + "eslint": "^8.8.0", + "eslint-plugin-react": "^7.28.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz", + "integrity": "sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==", + "dev": true, + "dependencies": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-n": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.7.0.tgz", + "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", + "dev": true, + "dependencies": { + "builtins": "^5.0.1", + "eslint-plugin-es": "^4.1.0", + "eslint-utils": "^3.0.0", + "ignore": "^5.1.1", + "is-core-module": "^2.11.0", + "minimatch": "^3.1.2", + "resolve": "^1.22.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-n/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-n/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/eslint-plugin-promise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", + "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", + "estraverse": "^5.3.0", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.4", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.8" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", + "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dependencies": { + "micromatch": "^4.0.2" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.2.5.tgz", + "integrity": "sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==", + "dev": true + }, + "node_modules/helmet": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-6.2.0.tgz", + "integrity": "sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-ci/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "21.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-21.1.2.tgz", + "integrity": "sha512-sCpFmK2jv+1sjff4u7fzft+pUh2KSUbUrEHYHyfSIbGTIcmnjyp83qg6qLwdJ/I3LpTXx33ACxeRL7Lsyc6lGQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.2", + "acorn-globals": "^7.0.0", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=4", + "npm": ">=1.4.28" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/load-json-file": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz", + "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.15", + "parse-json": "^4.0.0", + "pify": "^4.0.1", + "strip-bom": "^3.0.0", + "type-fest": "^0.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/load-json-file/node_modules/type-fest": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz", + "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucia": { + "version": "3.0.0-beta.14", + "resolved": "https://registry.npmjs.org/lucia/-/lucia-3.0.0-beta.14.tgz", + "integrity": "sha512-MXJILHb4xyvf3qjO7w7mDnvVOub2LGWLSjgP1TBGPLDkBF62uXNfvPNH7QRvOwvuSLtQK+w7JoPjnjiFiIj9rg==", + "dependencies": { + "oslo": "^0.27.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mitt": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.0.tgz", + "integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==" + }, + "node_modules/mlly": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.5.0.tgz", + "integrity": "sha512-NPVQvAY1xr1QoVeG0cy8yUYC7FQcOx6evl/RjT1wL5FvzPnzOysoqB/jmx/DhssT2dYa8nxECLAaFI/+gVLhDQ==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.0.3", + "ufo": "^1.3.2" + } + }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/msw": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-1.3.2.tgz", + "integrity": "sha512-wKLhFPR+NitYTkQl5047pia0reNGgf0P6a1eTnA5aNlripmiz0sabMvvHcicE8kQ3/gZcI0YiPFWmYfowfm3lA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@mswjs/cookies": "^0.2.2", + "@mswjs/interceptors": "^0.17.10", + "@open-draft/until": "^1.0.3", + "@types/cookie": "^0.4.1", + "@types/js-levenshtein": "^1.1.1", + "chalk": "^4.1.1", + "chokidar": "^3.4.2", + "cookie": "^0.4.2", + "graphql": "^16.8.1", + "headers-polyfill": "3.2.5", + "inquirer": "^8.2.0", + "is-node-process": "^1.2.0", + "js-levenshtein": "^1.1.6", + "node-fetch": "^2.6.7", + "outvariant": "^1.4.0", + "path-to-regexp": "^6.2.0", + "strict-event-emitter": "^0.4.3", + "type-fest": "^2.19.0", + "yargs": "^17.3.1" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.4.x <= 5.2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "node_modules/nanoassert": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-1.1.0.tgz", + "integrity": "sha512-C40jQ3NzfkP53NsO8kEOFd79p4b9kDXQMwgiY1z8ZwrDZgUyom0AHwGegF4Dm99L+YoYhuaB0ceerUcXmqr1rQ==" + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", + "integrity": "sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", + "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/object.hasown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.3.tgz", + "integrity": "sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oslo": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/oslo/-/oslo-0.27.1.tgz", + "integrity": "sha512-AYU0LpwZ50wIMD3dr4NX0tQzjwxaejSSV9reiY9jbQfgODt49al3f3tMcijyddyvPknUPArz845vpswWyJTWvA==", + "dependencies": { + "@node-rs/argon2": "^1.5.2", + "@node-rs/bcrypt": "^1.7.3" + } + }, + "node_modules/outvariant": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.2.tgz", + "integrity": "sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==", + "dev": true + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/patch-package": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-6.5.1.tgz", + "integrity": "sha512-I/4Zsalfhc6bphmJTlrLoOcAF87jcxko4q0qsv4bGcurbr8IskEOtdnt9iCmsQVGL1B+iUhSQqweyTLJfCF9rA==", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "cross-spawn": "^6.0.5", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "is-ci": "^2.0.0", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^5.6.0", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^1.10.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=10", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz", + "integrity": "sha512-m0OTbR/5VPNPqO1ph6Fqbj7Hv6QU7gR/tQW40ZqrL1rjgCU85W6C1bJn0BItuJqnR98PWzw7Z8hHeChD1WrgdQ==", + "dev": true, + "dependencies": { + "find-up": "^3.0.0", + "load-json-file": "^5.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", + "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", + "dev": true, + "dependencies": { + "jsonc-parser": "^3.2.0", + "mlly": "^1.2.0", + "pathe": "^1.1.0" + } + }, + "node_modules/postcss": { + "version": "8.4.33", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", + "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.16.2.tgz", + "integrity": "sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "4.16.2" + }, + "bin": { + "prisma": "build/index.js", + "prisma2": "build/index.js" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rate-limiter-flexible": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.4.2.tgz", + "integrity": "sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw==" + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.49.3", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.49.3.tgz", + "integrity": "sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==", + "engines": { + "node": ">=18", + "pnpm": "8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-dom": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/react-router/node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/react-router/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", + "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rollup": { + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", + "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", + "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/secure-password": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/secure-password/-/secure-password-4.0.0.tgz", + "integrity": "sha512-B268T/tx+hq7q85KH6gonEqK/lhrLhNtzYzqojuMtBPVFBtwiIwxqF+4yr9POsJu5cIxbJyM66eYfXZiPZUXRA==", + "dependencies": { + "nanoassert": "^1.0.0", + "sodium-native": "^3.1.1" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/server": { + "resolved": ".wasp/out/server", + "link": true + }, + "node_modules/set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, + "node_modules/set-function-length": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", + "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", + "dependencies": { + "define-data-property": "^1.1.1", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.2", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "dev": true, + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sodium-native": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-3.4.1.tgz", + "integrity": "sha512-PaNN/roiFWzVVTL6OqjzYct38NSXewdl2wz8SRB51Br/MLIJPrbM3XexhVWkq7D3UWMysfrhKVf1v1phZq6MeQ==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/standard": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.0.tgz", + "integrity": "sha512-jaDqlNSzLtWYW4lvQmU0EnxWMUGQiwHasZl5ZEIwx3S/ijZDjZOzs1y1QqKwKs5vqnFpGtizo4NOYX2s0Voq/g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "eslint": "^8.41.0", + "eslint-config-standard": "17.1.0", + "eslint-config-standard-jsx": "^11.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-n": "^15.7.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.32.2", + "standard-engine": "^15.0.0", + "version-guard": "^1.1.1" + }, + "bin": { + "standard": "bin/cmd.cjs" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/standard-engine": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/standard-engine/-/standard-engine-15.1.0.tgz", + "integrity": "sha512-VHysfoyxFu/ukT+9v49d4BRXIokFRZuH3z1VRxzFArZdjSCFpro6rEIU3ji7e4AoAtuSfKBkiOmsrDqKW5ZSRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "get-stdin": "^8.0.0", + "minimist": "^1.2.6", + "pkg-conf": "^3.1.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, + "node_modules/stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "dependencies": { + "internal-slot": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", + "integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==", + "dev": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", + "integrity": "sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "regexp.prototype.flags": "^1.5.0", + "set-function-name": "^2.0.0", + "side-channel": "^1.0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/superjson": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.3.tgz", + "integrity": "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "node_modules/tinybench": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", + "integrity": "sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.4.0.tgz", + "integrity": "sha512-2ksntHOKf893wSAH4z/+JbPpi92esw8Gn9N2deXX+B0EO92hexAVI9GIZZPx7P5aYo5KULfeOSt3kMOmSOy6uA==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-1.1.1.tgz", + "integrity": "sha512-UVq5AXt/gQlti7oxoIg5oi/9r0WpF7DGEVwXgqWSMmyN16+e3tl5lIvTaOpJ3TAtu5xFzWccFRM4R5NaWHF+4g==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/TodoTypescript": { + "resolved": ".wasp/out/web-app", + "link": true + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "devOptional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", + "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", + "dev": true + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/version-guard": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/version-guard/-/version-guard-1.1.1.tgz", + "integrity": "sha512-MGQLX89UxmYHgDvcXyjBI0cbmoW+t/dANDppNPrno64rYr8nH4SHSuElQuSYdXGEs0mUzdQe1BY+FhVPNsAmJQ==", + "dev": true, + "engines": { + "node": ">=0.10.48" + } + }, + "node_modules/vite": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", + "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "0.29.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.29.8.tgz", + "integrity": "sha512-b6OtCXfk65L6SElVM20q5G546yu10/kNrhg08afEoWlFRJXFq9/6glsvSVY+aI6YeC1tu2TtAqI2jHEQmOmsFw==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "mlly": "^1.1.0", + "pathe": "^1.1.0", + "picocolors": "^1.0.0", + "vite": "^3.0.0 || ^4.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": ">=v14.16.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vitest": { + "version": "0.29.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.29.8.tgz", + "integrity": "sha512-JIAVi2GK5cvA6awGpH0HvH/gEG9PZ0a/WoxdiV3PmqK+3CjQMf8c+J/Vhv4mdZ2nRyXFw66sAg6qz7VNkaHfDQ==", + "dev": true, + "dependencies": { + "@types/chai": "^4.3.4", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.29.8", + "@vitest/runner": "0.29.8", + "@vitest/spy": "0.29.8", + "@vitest/utils": "0.29.8", + "acorn": "^8.8.1", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.7", + "debug": "^4.3.4", + "local-pkg": "^0.4.2", + "pathe": "^1.1.0", + "picocolors": "^1.0.0", + "source-map": "^0.6.1", + "std-env": "^3.3.1", + "strip-literal": "^1.0.0", + "tinybench": "^2.3.1", + "tinypool": "^0.4.0", + "tinyspy": "^1.0.2", + "vite": "^3.0.0 || ^4.0.0", + "vite-node": "0.29.8", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.16.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/wasp": { + "resolved": ".wasp/out/sdk/wasp", + "link": true + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dev": true, + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", + "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", + "dev": true, + "dependencies": { + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/waspc/examples/todo-typescript/package.json b/waspc/examples/todo-typescript/package.json new file mode 100644 index 0000000000..f6d2cca6fa --- /dev/null +++ b/waspc/examples/todo-typescript/package.json @@ -0,0 +1,28 @@ +{ + "name": "prototype", + "dependencies": { + "wasp": "file:.wasp/out/sdk/wasp", + "react": "^18.2.0" + }, + "devDependencies": { + "typescript": "^5.1.0", + "vite": "^4.3.9", + "@types/react": "^18.0.37", + "prisma": "4.16.2" + }, + "workspaces": [ + ".wasp/out/sdk/wasp", + ".wasp/out/web-app", + ".wasp/out/server" + ], + "//": [ + "COMMENTS:", + { + "devDependencies.prisma": [ + "We on purpose specify exact version for prisma.", + "Check this GH comment for the reasoning behind it:", + "https://github.com/wasp-lang/wasp/pull/634#issuecomment-1158802302 ." + ] + } + ] +} \ No newline at end of file diff --git a/waspc/examples/todo-typescript/src/.waspignore b/waspc/examples/todo-typescript/src/.waspignore new file mode 100644 index 0000000000..1c432f30d9 --- /dev/null +++ b/waspc/examples/todo-typescript/src/.waspignore @@ -0,0 +1,3 @@ +# Ignore editor tmp files +**/*~ +**/#*# diff --git a/waspc/examples/todo-typescript/src/Main.css b/waspc/examples/todo-typescript/src/Main.css new file mode 100644 index 0000000000..15a61f2399 --- /dev/null +++ b/waspc/examples/todo-typescript/src/Main.css @@ -0,0 +1,73 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; +} + +main { + padding: 1rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +h1 { + padding: 0; + margin: 1rem 0; +} + +main p { + font-size: 1rem; +} + +img { + max-height: 100px; +} + +.logout { + margin-top: 1rem; +} + +code { + border-radius: 5px; + padding: 0.2rem; + background: #efefef; + font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, + Bitstream Vera Sans Mono, Courier New, monospace; +} + +.auth-form h2 { + margin-top: 0.5rem; + font-size: 1.2rem; +} + +.buttons { + display: flex; + flex-direction: row; + width: 300px; + justify-content: space-between; +} + +.tasklist { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: 300px; + margin-top: 1rem; + padding: 0 +} + +li { + width: 100%; +} + +.todo-item { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} diff --git a/waspc/examples/todo-typescript/src/MainPage.tsx b/waspc/examples/todo-typescript/src/MainPage.tsx new file mode 100644 index 0000000000..3d43e28deb --- /dev/null +++ b/waspc/examples/todo-typescript/src/MainPage.tsx @@ -0,0 +1,107 @@ +import './Main.css' +import React, { useEffect, FormEventHandler, FormEvent } from 'react' +import logout from 'wasp/auth/logout' +import { useQuery, useAction } from 'wasp/rpc' // Wasp uses a thin wrapper around react-query +import { getTasks } from 'wasp/rpc/queries' +import { createTask, updateTask, deleteTasks } from 'wasp/rpc/actions' +import waspLogo from './waspLogo.png' +import type { Task } from 'wasp/entities' +import type { User } from 'wasp/auth/types' +import { getUsername } from 'wasp/auth/user' + +export const MainPage = ({ user }: { user: User }) => { + const { data: tasks, isLoading, error } = useQuery(getTasks) + + if (isLoading) return 'Loading...' + if (error) return 'Error: ' + error + + const completed = tasks?.filter((task) => task.isDone).map((task) => task.id) + + return ( +
+ wasp logo + {user && ( +

+ {getUsername(user)} + {`'s tasks :)`} +

+ )} + + {tasks && } +
+ + +
+
+ ) +} + +function Todo({ id, isDone, description }: Task) { + const handleIsDoneChange: FormEventHandler = async ( + event + ) => { + try { + await updateTask({ + id, + isDone: event.currentTarget.checked, + }) + } catch (err: any) { + window.alert('Error while updating task ' + err?.message) + } + } + + return ( +
  • + + + {description} + + +
  • + ) +} + +function TasksList({ tasks }: { tasks: Task[] }) { + if (tasks.length === 0) return

    No tasks yet.

    + return ( +
      + {tasks.map((task, idx) => ( + + ))} +
    + ) +} + +function NewTaskForm() { + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + + try { + const description = event.currentTarget.description.value + console.log(description) + event.currentTarget.reset() + await createTask({ description }) + } catch (err: any) { + window.alert('Error: ' + err?.message) + } + } + + return ( +
    + + +
    + ) +} diff --git a/waspc/examples/todo-typescript/src/task/actions.ts b/waspc/examples/todo-typescript/src/task/actions.ts new file mode 100644 index 0000000000..c03bfac62b --- /dev/null +++ b/waspc/examples/todo-typescript/src/task/actions.ts @@ -0,0 +1,56 @@ +import HttpError from 'wasp/core/HttpError' +import type { + CreateTask, + UpdateTask, + DeleteTasks, +} from 'wasp/server/actions/types' +import type { Task } from 'wasp/entities' + +type CreateArgs = Pick + +export const createTask: CreateTask = async ( + { description }, + context +) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.create({ + data: { + description, + user: { connect: { id: context.user.id } }, + }, + }) +} + +type UpdateArgs = Pick + +export const updateTask: UpdateTask = async ( + { id, isDone }, + context +) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.update({ + where: { + id, + }, + data: { isDone }, + }) +} + +export const deleteTasks: DeleteTasks = async ( + idsToDelete, + context +) => { + return context.entities.Task.deleteMany({ + where: { + id: { + in: idsToDelete, + }, + }, + }) +} diff --git a/waspc/examples/todo-typescript/src/task/queries.ts b/waspc/examples/todo-typescript/src/task/queries.ts new file mode 100644 index 0000000000..ac49e0a7a7 --- /dev/null +++ b/waspc/examples/todo-typescript/src/task/queries.ts @@ -0,0 +1,15 @@ +import HttpError from 'wasp/core/HttpError' +import type { GetTasks } from 'wasp/server/queries/types' +import type { Task } from 'wasp/entities' + +//Using TypeScript's new 'satisfies' keyword, it will infer the types of the arguments and return value +export const getTasks = ((_args, context) => { + if (!context.user) { + throw new HttpError(401) + } + + return context.entities.Task.findMany({ + where: { user: { id: context.user.id } }, + orderBy: { id: 'asc' }, + }) +}) satisfies GetTasks diff --git a/waspc/examples/todo-typescript/src/user/LoginPage.tsx b/waspc/examples/todo-typescript/src/user/LoginPage.tsx new file mode 100644 index 0000000000..fa198154ab --- /dev/null +++ b/waspc/examples/todo-typescript/src/user/LoginPage.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router-dom' +import { LoginForm } from 'wasp/auth/forms/Login' + +export function LoginPage() { + return ( +
    + {/** Wasp has built-in auth forms & flows, which you can customize or opt-out of, if you wish :) + * https://wasp-lang.dev/docs/guides/auth-ui + */} + +
    + + I don't have an account yet (go to signup). + +
    + ) +} diff --git a/waspc/examples/todo-typescript/src/user/SignupPage.tsx b/waspc/examples/todo-typescript/src/user/SignupPage.tsx new file mode 100644 index 0000000000..e3599729d6 --- /dev/null +++ b/waspc/examples/todo-typescript/src/user/SignupPage.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router-dom' +import { SignupForm } from 'wasp/auth/forms/Signup' + +export function SignupPage() { + return ( +
    + {/** Wasp has built-in auth forms & flows, which you can customize or opt-out of, if you wish :) + * https://wasp-lang.dev/docs/guides/auth-ui + */} + +
    + + I already have an account (go to login). + +
    + ) +} diff --git a/waspc/examples/todo-typescript/src/vite-env.d.ts b/waspc/examples/todo-typescript/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/waspc/examples/todo-typescript/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/waspc/examples/todo-typescript/src/waspLogo.png b/waspc/examples/todo-typescript/src/waspLogo.png new file mode 100644 index 0000000000..d39a9443a8 Binary files /dev/null and b/waspc/examples/todo-typescript/src/waspLogo.png differ diff --git a/waspc/examples/todo-typescript/tsconfig.json b/waspc/examples/todo-typescript/tsconfig.json new file mode 100644 index 0000000000..93c79bf3d8 --- /dev/null +++ b/waspc/examples/todo-typescript/tsconfig.json @@ -0,0 +1,28 @@ +// =============================== IMPORTANT ================================= +// +// This file is only used for Wasp IDE support. You can change it to configure +// your IDE checks, but none of these options will affect the TypeScript +// compiler. Proper TS compiler configuration in Wasp is coming soon :) +{ + "compilerOptions": { + // JSX support + "jsx": "preserve", + "strict": true, + // Allow default imports. + "esModuleInterop": true, + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + // Since this TS config is used only for IDE support and not for + // compilation, the following directory doesn't exist. We need to specify + // it to prevent this error: + // https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file + "outDir": "phantom" + }, + "exclude": [ + "phantom" + ], +} \ No newline at end of file diff --git a/waspc/examples/todoApp/migrations/20240110132515_add_session/migration.sql b/waspc/examples/todoApp/migrations/20240110132515_add_session/migration.sql new file mode 100644 index 0000000000..47f98ace24 --- /dev/null +++ b/waspc/examples/todoApp/migrations/20240110132515_add_session/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/todoApp/src/server/auth/email.ts b/waspc/examples/todoApp/src/server/auth/email.ts index 6af04c1bae..d43c2f5500 100644 --- a/waspc/examples/todoApp/src/server/auth/email.ts +++ b/waspc/examples/todoApp/src/server/auth/email.ts @@ -2,6 +2,7 @@ import { GetPasswordResetEmailContentFn, GetVerificationEmailContentFn, } from '@wasp/types' +import { defineUserSignupFields } from '@wasp/auth/index.js' export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink, @@ -24,3 +25,15 @@ export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ Verify email `, }) + +export const userSignupFields = defineUserSignupFields({ + address: (data) => { + if (typeof data.address !== 'string') { + throw new Error('Address is required.') + } + if (data.address.length < 10) { + throw new Error('Address must be at least 10 characters long.') + } + return data.address + }, +}) diff --git a/waspc/examples/todoApp/src/server/auth/github.js b/waspc/examples/todoApp/src/server/auth/github.js index 930bbc5690..63f6dbb62f 100644 --- a/waspc/examples/todoApp/src/server/auth/github.js +++ b/waspc/examples/todoApp/src/server/auth/github.js @@ -1,16 +1,10 @@ -import { generateAvailableUsername } from '@wasp/core/auth.js' - export function config() { - console.log("Inside user-supplied GitHub config") + console.log('Inside user-supplied GitHub config') return { clientID: process.env['GITHUB_CLIENT_ID'], clientSecret: process.env['GITHUB_CLIENT_SECRET'], - scope: [] + scope: [], } } -export async function getUserFields(_context, args) { - console.log("Inside user-supplied GitHub getUserFields") - const username = await generateAvailableUsername([args.profile.username], { separator: '-' }) - return { username } -} +export const userSignupFields = {} diff --git a/waspc/examples/todoApp/src/server/auth/google.js b/waspc/examples/todoApp/src/server/auth/google.js index 3bee3abcd9..9071d973f6 100644 --- a/waspc/examples/todoApp/src/server/auth/google.js +++ b/waspc/examples/todoApp/src/server/auth/google.js @@ -7,7 +7,4 @@ export function config() { } } -export async function getUserFields(_context, args) { - console.log('Inside user-supplied Google getUserFields') - return {} -} +export const userSignupFields = {} diff --git a/waspc/examples/todoApp/src/server/auth/signup.ts b/waspc/examples/todoApp/src/server/auth/signup.ts index d2e9414dbf..35043eeced 100644 --- a/waspc/examples/todoApp/src/server/auth/signup.ts +++ b/waspc/examples/todoApp/src/server/auth/signup.ts @@ -1,6 +1,6 @@ -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: (data) => { if (typeof data.address !== 'string') { throw new Error('Address is required.') diff --git a/waspc/examples/todoApp/src/server/dbSeeds.ts b/waspc/examples/todoApp/src/server/dbSeeds.ts index b5ec2528fc..e7a418ce76 100644 --- a/waspc/examples/todoApp/src/server/dbSeeds.ts +++ b/waspc/examples/todoApp/src/server/dbSeeds.ts @@ -1,7 +1,7 @@ import { createTask } from './actions.js' import type { DbSeedFn } from '@wasp/dbSeed/types.js' import { PrismaClient } from '@prisma/client/index.js' -import { hashPassword } from '@wasp/core/auth.js' +import { hashPassword } from '@wasp/auth/password.js' async function createUser(prismaClient: PrismaClient, data: any) { const newUser = await prismaClient.user.create({ diff --git a/waspc/examples/todoApp/todoApp.wasp b/waspc/examples/todoApp/todoApp.wasp index 58bd869233..e70f28ab6e 100644 --- a/waspc/examples/todoApp/todoApp.wasp +++ b/waspc/examples/todoApp/todoApp.wasp @@ -15,16 +15,19 @@ app todoApp { auth: { userEntity: User, methods: { - // usernameAndPassword: {}, + // usernameAndPassword: { + // userSignupFields: import { userSignupFields } from "@server/auth/github.js", + // }, google: { configFn: import { config } from "@server/auth/google.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + userSignupFields: import { userSignupFields } from "@server/auth/google.js" }, // gitHub: { // // configFn: import { config } from "@server/auth/github.js", - // // getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + // // userSignupFields: import { getUserFields } from "@server/auth/github.js" // }, email: { + userSignupFields: import { userSignupFields } from "@server/auth/email.js", fromField: { name: "ToDO App", email: "mihovil@ilakovac.com" @@ -37,12 +40,8 @@ app todoApp { getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", clientRoute: PasswordResetRoute }, - allowUnverifiedLogin: false, }, }, - signup: { - additionalFields: import { fields } from "@server/auth/signup.js", - }, onAuthFailedRedirectTo: "/login", onAuthSucceededRedirectTo: "/profile" }, diff --git a/waspc/headless-test/examples/todoApp/migrations/20240115130723_add_session/migration.sql b/waspc/headless-test/examples/todoApp/migrations/20240115130723_add_session/migration.sql new file mode 100644 index 0000000000..47f98ace24 --- /dev/null +++ b/waspc/headless-test/examples/todoApp/migrations/20240115130723_add_session/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/headless-test/examples/todoApp/sample.env.server b/waspc/headless-test/examples/todoApp/sample.env.server index b590869737..fd6dccba45 100644 --- a/waspc/headless-test/examples/todoApp/sample.env.server +++ b/waspc/headless-test/examples/todoApp/sample.env.server @@ -1,2 +1,3 @@ GOOGLE_CLIENT_ID="mock-client-id" -GOOGLE_CLIENT_SECRET="mock-client-secret" \ No newline at end of file +GOOGLE_CLIENT_SECRET="mock-client-secret" +SKIP_EMAIL_VERIFICATION_IN_DEV=true \ No newline at end of file diff --git a/waspc/headless-test/examples/todoApp/todoApp.wasp b/waspc/headless-test/examples/todoApp/todoApp.wasp index eabd93d2dd..f496314af0 100644 --- a/waspc/headless-test/examples/todoApp/todoApp.wasp +++ b/waspc/headless-test/examples/todoApp/todoApp.wasp @@ -22,8 +22,7 @@ app todoApp { passwordReset: { getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", clientRoute: PasswordResetRoute - }, - allowUnverifiedLogin: true, + } }, google: {} }, diff --git a/waspc/headless-test/tests/simple.spec.ts b/waspc/headless-test/tests/simple.spec.ts index 7706f56a1d..5fedcfc189 100644 --- a/waspc/headless-test/tests/simple.spec.ts +++ b/waspc/headless-test/tests/simple.spec.ts @@ -17,7 +17,9 @@ test.describe("signup and login", () => { await page.waitForSelector("text=Create a new account"); - await expect(page.locator("a[href='http://localhost:3001/auth/google/login']")).toBeVisible(); + await expect( + page.locator("a[href='http://localhost:3001/auth/google/login']") + ).toBeVisible(); }); test("can sign up", async ({ page }) => { @@ -29,7 +31,9 @@ test.describe("signup and login", () => { await page.locator("input[type='password']").fill(password); await page.locator("button").click(); - await expect(page).toHaveURL("/profile"); + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.` + ); }); test("can log in and create a task", async ({ page }) => { diff --git a/waspc/src/Wasp/AI/CodeAgent.hs b/waspc/src/Wasp/AI/CodeAgent.hs index d67a50a006..971986565b 100644 --- a/waspc/src/Wasp/AI/CodeAgent.hs +++ b/waspc/src/Wasp/AI/CodeAgent.hs @@ -73,7 +73,10 @@ runCodeAgent config codeAgent = _isGpt4Available = Nothing } - shortenWithEllipsisTo maxLen text = if length text <= maxLen then text else (take maxLen text) <> "..." + shortenWithEllipsisTo maxLen text = + if length text <= maxLen + then text + else take maxLen text <> "..." showShortException :: forall e. Exception e => e -> String showShortException = shortenWithEllipsisTo 30 . displayException diff --git a/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs b/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs index e0da203357..26598d58e2 100644 --- a/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs +++ b/waspc/src/Wasp/Analyzer/Evaluator/Evaluation/TypedExpr/Combinators.hs @@ -17,7 +17,6 @@ module Wasp.Analyzer.Evaluator.Evaluation.TypedExpr.Combinators ) where -import Control.Applicative ((<|>)) import Control.Arrow (left) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Lazy.UTF8 as ByteStringLazyUTF8 @@ -169,13 +168,21 @@ extImport = evaluation' . withCtx $ \ctx -> \case Nothing -> mkParseError ctx - $ "Path in external import must start with \"" ++ serverPrefix ++ "\"" ++ " or \"" ++ clientPrefix ++ "\"!" + $ "Path in external import must start with \"" ++ extSrcPrefix ++ "\"!" expr -> Left $ ER.mkEvaluationError ctx $ ER.ExpectedType T.ExtImportType (TypedAST.exprType expr) where mkParseError ctx msg = Left $ ER.mkEvaluationError ctx $ ER.ParseError $ ER.EvaluationParseError msg - stripImportPrefix importPath = stripPrefix serverPrefix importPath <|> stripPrefix clientPrefix importPath - serverPrefix = "@server/" - clientPrefix = "@client/" + stripImportPrefix importPath = stripPrefix extSrcPrefix importPath + -- Filip: We no longer want separation between client and server code + -- todo (filip): Do we still want to know whic is which. We might (because of the reloading). + -- For now, as we'd like (expect): + -- - Nodemon watches all files in the user's source folder (client files + -- included), but tsc only compiles the server files (I think because it + -- knows that the others aren't used). I am not yet sure how it knows this. + -- - Vite also only triggers on client files. I am not sure how it knows + -- about the difference either. + -- todo (filip): investigate + extSrcPrefix = "@src/" -- | An evaluation that expects a "JSON". json :: TypedExprEvaluation AppSpec.JSON.JSON diff --git a/waspc/src/Wasp/AppSpec.hs b/waspc/src/Wasp/AppSpec.hs index e4d8e8498d..ed75ad9d08 100644 --- a/waspc/src/Wasp/AppSpec.hs +++ b/waspc/src/Wasp/AppSpec.hs @@ -38,11 +38,12 @@ import Wasp.AppSpec.Core.Decl (Decl, IsDecl, takeDecls) import Wasp.AppSpec.Core.Ref (Ref, refName) import Wasp.AppSpec.Crud (Crud) import Wasp.AppSpec.Entity (Entity) -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) -import qualified Wasp.AppSpec.ExternalCode as ExternalCode +import Wasp.AppSpec.ExternalFiles (SourceExternalCodeDir) +import qualified Wasp.AppSpec.ExternalFiles as ExternalFiles import Wasp.AppSpec.Job (Job) import Wasp.AppSpec.Operation (Operation) import qualified Wasp.AppSpec.Operation as AS.Operation +import Wasp.AppSpec.PackageJson (PackageJson) import Wasp.AppSpec.Page (Page) import Wasp.AppSpec.Query (Query) import Wasp.AppSpec.Route (Route) @@ -59,15 +60,13 @@ import qualified Wasp.SemanticVersion as SV data AppSpec = AppSpec { -- | List of declarations like App, Page, Route, ... that describe the web app. decls :: [Decl], + -- | The contents of the package.json file found in the root directory of the wasp project. + packageJson :: PackageJson, -- | Absolute path to the directory containing the wasp project. waspProjectDir :: Path' Abs (Dir WaspProjectDir), - -- | List of external server code files (they are referenced/used in the declarations). - externalServerFiles :: [ExternalCode.File], - -- | List of external client code files (they are referenced/used in the declarations). - externalClientFiles :: [ExternalCode.File], - -- | List of files with external code shared between the server and the client. - externalSharedFiles :: [ExternalCode.File], - -- | Absolute path to the directory in wasp project source that contains external code files. + -- | List of external code files (they are referenced/used in the declarations). + externalCodeFiles :: [ExternalFiles.CodeFile], + externalPublicFiles :: [ExternalFiles.PublicFile], migrationsDir :: Maybe (Path' Abs (Dir DbMigrationsDir)), -- | Env variables to be provided to the server only during the development. devEnvVarsServer :: [EnvVar], diff --git a/waspc/src/Wasp/AppSpec/App.hs b/waspc/src/Wasp/AppSpec/App.hs index 9e48ce9dfc..ebb145e7a5 100644 --- a/waspc/src/Wasp/AppSpec/App.hs +++ b/waspc/src/Wasp/AppSpec/App.hs @@ -6,7 +6,6 @@ import Data.Data (Data) import Wasp.AppSpec.App.Auth (Auth) import Wasp.AppSpec.App.Client (Client) import Wasp.AppSpec.App.Db (Db) -import Wasp.AppSpec.App.Dependency (Dependency) import Wasp.AppSpec.App.EmailSender (EmailSender) import Wasp.AppSpec.App.Server (Server) import Wasp.AppSpec.App.Wasp (Wasp) @@ -22,7 +21,6 @@ data App = App client :: Maybe Client, db :: Maybe Db, emailSender :: Maybe EmailSender, - dependencies :: Maybe [Dependency], webSocket :: Maybe WebSocket } deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/AppSpec/App/Auth.hs b/waspc/src/Wasp/AppSpec/App/Auth.hs index f1f7e1df87..0739bbd067 100644 --- a/waspc/src/Wasp/AppSpec/App/Auth.hs +++ b/waspc/src/Wasp/AppSpec/App/Auth.hs @@ -6,14 +6,15 @@ module Wasp.AppSpec.App.Auth AuthMethods (..), ExternalAuthConfig (..), EmailAuthConfig (..), - SignupOptions (..), - usernameAndPasswordConfig, + UsernameAndPasswordConfig (..), isUsernameAndPasswordAuthEnabled, isExternalAuthEnabled, isGoogleAuthEnabled, isGitHubAuthEnabled, isEmailAuthEnabled, - isEmailVerificationRequired, + userSignupFieldsForEmailAuth, + userSignupFieldsForUsernameAuth, + userSignupFieldsForExternalAuth, ) where @@ -30,7 +31,6 @@ data Auth = Auth { userEntity :: Ref Entity, externalAuthEntity :: Maybe (Ref Entity), methods :: AuthMethods, - signup :: Maybe SignupOptions, onAuthFailedRedirectTo :: String, onAuthSucceededRedirectTo :: Maybe String } @@ -45,33 +45,24 @@ data AuthMethods = AuthMethods deriving (Show, Eq, Data) data UsernameAndPasswordConfig = UsernameAndPasswordConfig - { -- NOTE: Not used right now, but Analyzer does not support an empty data type. - configFn :: Maybe ExtImport + { userSignupFields :: Maybe ExtImport } deriving (Show, Eq, Data) data ExternalAuthConfig = ExternalAuthConfig { configFn :: Maybe ExtImport, - getUserFieldsFn :: Maybe ExtImport + userSignupFields :: Maybe ExtImport } deriving (Show, Eq, Data) data EmailAuthConfig = EmailAuthConfig - { fromField :: EmailFromField, + { userSignupFields :: Maybe ExtImport, + fromField :: EmailFromField, emailVerification :: EmailVerificationConfig, - passwordReset :: PasswordResetConfig, - allowUnverifiedLogin :: Maybe Bool + passwordReset :: PasswordResetConfig } deriving (Show, Eq, Data) -data SignupOptions = SignupOptions - { additionalFields :: Maybe ExtImport - } - deriving (Show, Eq, Data) - -usernameAndPasswordConfig :: UsernameAndPasswordConfig -usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing - isUsernameAndPasswordAuthEnabled :: Auth -> Bool isUsernameAndPasswordAuthEnabled = isJust . usernameAndPassword . methods @@ -87,7 +78,14 @@ isGitHubAuthEnabled = isJust . gitHub . methods isEmailAuthEnabled :: Auth -> Bool isEmailAuthEnabled = isJust . email . methods -isEmailVerificationRequired :: Auth -> Bool -isEmailVerificationRequired auth = case email . methods $ auth of - Nothing -> False - Just emailAuthConfig -> allowUnverifiedLogin emailAuthConfig /= Just True +-- 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 diff --git a/waspc/src/Wasp/AppSpec/App/Dependency.hs b/waspc/src/Wasp/AppSpec/App/Dependency.hs index f355c3a11b..ecb3ab0d36 100644 --- a/waspc/src/Wasp/AppSpec/App/Dependency.hs +++ b/waspc/src/Wasp/AppSpec/App/Dependency.hs @@ -14,6 +14,8 @@ import GHC.Generics data Dependency = Dependency { name :: String, + -- | NOTE: By npm docs, this can be semver version range, + -- but it can also be a URL (tarball, git or Github), or a local file path. version :: String } deriving (Show, Eq, Data, Generic) diff --git a/waspc/src/Wasp/AppSpec/App/EmailSender.hs b/waspc/src/Wasp/AppSpec/App/EmailSender.hs index ee10a40908..7721f579bd 100644 --- a/waspc/src/Wasp/AppSpec/App/EmailSender.hs +++ b/waspc/src/Wasp/AppSpec/App/EmailSender.hs @@ -15,7 +15,7 @@ data EmailSender = EmailSender } deriving (Show, Eq, Data) -data EmailProvider = SMTP | SendGrid | Mailgun +data EmailProvider = SMTP | SendGrid | Mailgun | Dummy deriving (Eq, Data, Show) data EmailFromField = EmailFromField diff --git a/waspc/src/Wasp/AppSpec/ExtImport.hs b/waspc/src/Wasp/AppSpec/ExtImport.hs index 4680d75e37..cc2b347826 100644 --- a/waspc/src/Wasp/AppSpec/ExtImport.hs +++ b/waspc/src/Wasp/AppSpec/ExtImport.hs @@ -13,7 +13,7 @@ import Data.Aeson (FromJSON, ToJSON) import Data.Data (Data) import GHC.Generics (Generic) import StrongPath (File', Path, Posix, Rel) -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) +import Wasp.AppSpec.ExternalFiles (SourceExternalCodeDir) data ExtImport = ExtImport { -- | What is being imported. diff --git a/waspc/src/Wasp/AppSpec/ExternalCode.hs b/waspc/src/Wasp/AppSpec/ExternalCode.hs deleted file mode 100644 index 7f83db7310..0000000000 --- a/waspc/src/Wasp/AppSpec/ExternalCode.hs +++ /dev/null @@ -1,52 +0,0 @@ -{-# LANGUAGE DeriveDataTypeable #-} - -module Wasp.AppSpec.ExternalCode - ( -- | Wasp project consists of Wasp code (.wasp files) and external code (e.g. .js files) that is - -- used/referenced by the Wasp code. - -- Therefore, the whole specification of the web app is not just Wasp code, but a combination of - -- Wasp code and external code. - -- Main unit of external code is File, and external code is currently all organized in a single - -- directory in Wasp project which we call source external code dir (source because it is in the - -- Wasp project \/ source dir, and not in the generated project \/ source). - File (..), - filePathInExtCodeDir, - fileAbsPath, - fileText, - SourceExternalCodeDir, - ) -where - -import Data.Data (Data) -import Data.Text (Text) -import qualified Data.Text.Lazy as TextL -import StrongPath (Abs, Dir, File', Path', Rel, ()) - --- | Directory in Wasp source that contains external code. --- External code files are obtained from it. -data SourceExternalCodeDir deriving (Data) - -data File = File - { _pathInExtCodeDir :: !(Path' (Rel SourceExternalCodeDir) File'), - _extCodeDirPath :: !(Path' Abs (Dir SourceExternalCodeDir)), - -- | File content. Since it is lazy, it might throw error when evaluated, - -- since reading will happen only then. E.g. it will throw error if file is not a textual file. - _text :: TextL.Text - } - -instance Show File where - show = show . _pathInExtCodeDir - -instance Eq File where - f1 == f2 = _pathInExtCodeDir f1 == _pathInExtCodeDir f2 - --- | Returns path relative to the external code directory. -filePathInExtCodeDir :: File -> Path' (Rel SourceExternalCodeDir) File' -filePathInExtCodeDir = _pathInExtCodeDir - --- | Unsafe method: throws error if text could not be read (if file is not a textual file)! -fileText :: File -> Text -fileText = TextL.toStrict . _text - --- | Returns absolute path of the external code file. -fileAbsPath :: File -> Path' Abs File' -fileAbsPath file = _extCodeDirPath file _pathInExtCodeDir file diff --git a/waspc/src/Wasp/AppSpec/ExternalFiles.hs b/waspc/src/Wasp/AppSpec/ExternalFiles.hs new file mode 100644 index 0000000000..f75b39c69d --- /dev/null +++ b/waspc/src/Wasp/AppSpec/ExternalFiles.hs @@ -0,0 +1,67 @@ +{-# LANGUAGE DeriveDataTypeable #-} + +module Wasp.AppSpec.ExternalFiles + ( -- | Wasp project consists of Wasp code (.wasp files) and external files. + -- External files can be either: + -- - External code files (e.g., JS, TS) used/referenced by Wasp code. These + -- files reside in the \/src directory which we call the "source external code dir". + -- - External (static) public files served unaltered by Wasp. These + -- files reside in the \/public directory which we call the "source external public dir". + -- We call these directories "source" directories because they're found in + -- the project's source dir, not in the generated project's source dir. + -- + -- Therefore, the whole specification of the web app is not just Wasp code, but a combination of + -- Wasp code and external files. + CodeFile (..), + PublicFile (..), + filePathInExtCodeDir, + fileAbsPath, + fileText, + SourceExternalCodeDir, + SourceExternalPublicDir, + ) +where + +import Data.Data (Data) +import Data.Text (Text) +import qualified Data.Text.Lazy as TextL +import StrongPath (Abs, Dir, File', Path', Rel, ()) + +-- | Directory in the Wasp project that contains external code. +-- External code files are obtained from it. +data SourceExternalCodeDir deriving (Data) + +-- | Directory in Wasp project that contains external public static files. +-- Public files are obtained from it. +data SourceExternalPublicDir deriving (Data) + +data CodeFile = CodeFile + { _pathInExtCodeDir :: !(Path' (Rel SourceExternalCodeDir) File'), + _extCodeDirPath :: !(Path' Abs (Dir SourceExternalCodeDir)), + -- | File content. Since it is lazy, it might throw error when evaluated, + -- since reading will happen only then. E.g. it will throw error if file is not a textual file. + _text :: TextL.Text + } + +data PublicFile = PublicFile + { _pathInPublicDir :: !(Path' (Rel SourceExternalPublicDir) File'), + _publicDirPath :: !(Path' Abs (Dir SourceExternalPublicDir)) + } + +instance Show CodeFile where + show = show . _pathInExtCodeDir + +instance Eq CodeFile where + f1 == f2 = _pathInExtCodeDir f1 == _pathInExtCodeDir f2 + +-- | Returns path relative to the external code directory. +filePathInExtCodeDir :: CodeFile -> Path' (Rel SourceExternalCodeDir) File' +filePathInExtCodeDir = _pathInExtCodeDir + +-- | Unsafe method: throws error if text could not be read (if file is not a textual file)! +fileText :: CodeFile -> Text +fileText = TextL.toStrict . _text + +-- | Returns absolute path of the external code file. +fileAbsPath :: CodeFile -> Path' Abs File' +fileAbsPath file = _extCodeDirPath file _pathInExtCodeDir file diff --git a/waspc/src/Wasp/AppSpec/PackageJson.hs b/waspc/src/Wasp/AppSpec/PackageJson.hs new file mode 100644 index 0000000000..cc90def775 --- /dev/null +++ b/waspc/src/Wasp/AppSpec/PackageJson.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE DeriveGeneric #-} + +module Wasp.AppSpec.PackageJson where + +import Data.Aeson (FromJSON) +import Data.Map (Map) +import qualified Data.Map as M +import GHC.Generics (Generic) +import Wasp.AppSpec.App.Dependency (Dependency) +import qualified Wasp.AppSpec.App.Dependency as D + +data PackageJson = PackageJson + { name :: !String, + dependencies :: !(Map String String), + devDependencies :: !(Map String String) + } + deriving (Show, Generic) + +instance FromJSON PackageJson + +getDependencies :: PackageJson -> [Dependency] +getDependencies packageJson = D.fromList $ M.toList $ dependencies packageJson + +getDevDependencies :: PackageJson -> [Dependency] +getDevDependencies packageJson = D.fromList $ M.toList $ devDependencies packageJson diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index 02bb1d10a4..6880edd293 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -28,6 +28,7 @@ import qualified Wasp.AppSpec.App as App import qualified Wasp.AppSpec.App.Auth as Auth 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 import qualified Wasp.AppSpec.App.Wasp as Wasp import Wasp.AppSpec.Core.Decl (takeDecls) import qualified Wasp.AppSpec.Crud as AS.Crud @@ -70,6 +71,7 @@ validateAppSpec spec = validateUserEntity spec, validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec, validateEmailSenderIsDefinedIfEmailAuthIsUsed spec, + validateDummyEmailSenderIsNotUsedInProduction spec, validateDbIsPostgresIfPgBossUsed spec, validateApiRoutesAreUnique spec, validateApiNamespacePathsAreUnique spec, @@ -177,14 +179,21 @@ validateEmailSenderIsDefinedIfEmailAuthIsUsed :: AppSpec -> [ValidationError] validateEmailSenderIsDefinedIfEmailAuthIsUsed spec = case App.auth app of Nothing -> [] Just auth -> - if not $ Auth.isEmailAuthEnabled auth - then [] - else case App.emailSender app of - Nothing -> [GenericValidationError "app.emailSender must be specified when using email auth."] - Just _ -> [] + if Auth.isEmailAuthEnabled 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 app = snd $ getApp spec +validateDummyEmailSenderIsNotUsedInProduction :: AppSpec -> [ValidationError] +validateDummyEmailSenderIsNotUsedInProduction spec = + if AS.isBuild spec && isDummyEmailSenderUsed + then [GenericValidationError "app.emailSender must not be set to Dummy when building for production."] + else [] + where + isDummyEmailSenderUsed = (AS.EmailSender.provider <$> App.emailSender app) == Just AS.EmailSender.Dummy + app = snd $ getApp spec + validateApiRoutesAreUnique :: AppSpec -> [ValidationError] validateApiRoutesAreUnique spec = if null groupsOfConflictingRoutes diff --git a/waspc/src/Wasp/CompileOptions.hs b/waspc/src/Wasp/CompileOptions.hs index d0174cd9b7..02eb4d0ff3 100644 --- a/waspc/src/Wasp/CompileOptions.hs +++ b/waspc/src/Wasp/CompileOptions.hs @@ -4,17 +4,15 @@ module Wasp.CompileOptions where import StrongPath (Abs, Dir, Path') -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) import Wasp.Generator.Monad (GeneratorWarning) import Wasp.Message (SendMessage) +import Wasp.Project.Common (WaspProjectDir) -- TODO(martin): Should these be merged with Wasp data? Is it really a separate thing or not? -- It would be easier to pass around if it is part of Wasp data. But is it semantically correct? -- Maybe it is, even more than this! data CompileOptions = CompileOptions - { externalServerCodeDirPath :: !(Path' Abs (Dir SourceExternalCodeDir)), - externalClientCodeDirPath :: !(Path' Abs (Dir SourceExternalCodeDir)), - externalSharedCodeDirPath :: !(Path' Abs (Dir SourceExternalCodeDir)), + { waspProjectDirPath :: !(Path' Abs (Dir WaspProjectDir)), isBuild :: !Bool, -- We give the compiler the ability to send messages. The code that -- invokes the compiler (such as the CLI) can then implement a way diff --git a/waspc/src/Wasp/Generator.hs b/waspc/src/Wasp/Generator.hs index ab60e1c1d0..8ba308cc03 100644 --- a/waspc/src/Wasp/Generator.hs +++ b/waspc/src/Wasp/Generator.hs @@ -21,6 +21,7 @@ import Wasp.Generator.DbGenerator (genDb) import Wasp.Generator.DockerGenerator (genDockerFiles) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator, GeneratorError, GeneratorWarning, runGenerator) +import Wasp.Generator.SdkGenerator (genSdk) import Wasp.Generator.ServerGenerator (genServer) import Wasp.Generator.Setup (runSetup) import qualified Wasp.Generator.Start @@ -54,6 +55,7 @@ genApp :: AppSpec -> Generator [FileDraft] genApp spec = genWebApp spec <++> genServer spec + <++> genSdk spec <++> genDb spec <++> genDockerFiles spec <++> genConfigFiles spec diff --git a/waspc/src/Wasp/Generator/DbGenerator.hs b/waspc/src/Wasp/Generator/DbGenerator.hs index 660e924a4f..dda90bd13d 100644 --- a/waspc/src/Wasp/Generator/DbGenerator.hs +++ b/waspc/src/Wasp/Generator/DbGenerator.hs @@ -1,5 +1,3 @@ -{-# LANGUAGE TypeApplications #-} - module Wasp.Generator.DbGenerator ( genDb, warnIfDbNeedsMigration, @@ -32,7 +30,6 @@ import Wasp.Generator.DbGenerator.Common dbSchemaFileInDbTemplatesDir, dbSchemaFileInProjectRootDir, dbTemplatesDirInTemplatesDir, - prismaClientOutputDirEnvVar, ) import qualified Wasp.Generator.DbGenerator.Operations as DbOps import Wasp.Generator.FileDraft (FileDraft, createCopyDirFileDraft, createTemplateFileDraft) @@ -73,7 +70,6 @@ genPrismaSchema spec = do [ "modelSchemas" .= map entityToPslModelSchema entities, "datasourceProvider" .= datasourceProvider, "datasourceUrl" .= datasourceUrl, - "prismaClientOutputDir" .= makeEnvVarField Wasp.Generator.DbGenerator.Common.prismaClientOutputDirEnvVar, "prismaPreviewFeatures" .= prismaPreviewFeatures, "dbExtensions" .= dbExtensions ] @@ -108,7 +104,7 @@ genMigrationsDir spec = return $ createCopyDirFileDraft RemoveExistingDstDir gen postWriteDbGeneratorActions :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> IO ([GeneratorWarning], [GeneratorError]) postWriteDbGeneratorActions spec dstDir = do dbGeneratorWarnings <- maybeToList <$> warnIfDbNeedsMigration spec dstDir - dbGeneratorErrors <- maybeToList <$> genPrismaClients spec dstDir + dbGeneratorErrors <- maybeToList <$> generatePrismaClient spec dstDir return (dbGeneratorWarnings, dbGeneratorErrors) -- | Checks if user needs to run `wasp db migrate-dev` due to changes in schema.prisma, and if so, returns a warning. @@ -186,12 +182,12 @@ warnProjectDiffersFromDb projectRootDir = do "Wasp was unable to verify your database is up to date." <> " Running `wasp db migrate-dev` may fix this and will provide more info." -genPrismaClients :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> IO (Maybe GeneratorError) -genPrismaClients spec projectRootDir = +generatePrismaClient :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> IO (Maybe GeneratorError) +generatePrismaClient spec projectRootDir = ifM wasCurrentSchemaAlreadyGenerated (return Nothing) - generatePrismaClientsIfEntitiesExist + generatePrismaClientIfEntitiesExist where wasCurrentSchemaAlreadyGenerated :: IO Bool wasCurrentSchemaAlreadyGenerated = @@ -199,10 +195,10 @@ genPrismaClients spec projectRootDir = projectRootDir Wasp.Generator.DbGenerator.Common.dbSchemaChecksumOnLastGenerateFileProjectRootDir - generatePrismaClientsIfEntitiesExist :: IO (Maybe GeneratorError) - generatePrismaClientsIfEntitiesExist + generatePrismaClientIfEntitiesExist :: IO (Maybe GeneratorError) + generatePrismaClientIfEntitiesExist | entitiesExist = - either (Just . GenericGeneratorError) (const Nothing) <$> DbOps.generatePrismaClients projectRootDir + either (Just . GenericGeneratorError) (const Nothing) <$> DbOps.generatePrismaClient projectRootDir | otherwise = return Nothing entitiesExist = not . null $ getEntities spec diff --git a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs index 849b45ee1a..b6e0989692 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs @@ -12,6 +12,7 @@ import Wasp.Generator.Monad ) import qualified Wasp.Psl.Ast.Model as Psl.Model import qualified Wasp.Psl.Ast.Model as Psl.Model.Field +import qualified Wasp.Util as Util {-- @@ -59,12 +60,22 @@ authIdentityEntityName = "AuthIdentity" identitiesFieldOnAuthEntityName :: String identitiesFieldOnAuthEntityName = "identities" +sessionEntityName :: String +sessionEntityName = "Session" + +sessionsFieldOnAuthEntityName :: String +sessionsFieldOnAuthEntityName = "sessions" + +authFieldOnSessionEntityName :: String +authFieldOnSessionEntityName = Util.toLowerFirst authEntityName + injectAuth :: [(String, AS.Entity.Entity)] -> (String, AS.Entity.Entity) -> Generator [(String, AS.Entity.Entity)] injectAuth entities (userEntityName, userEntity) = do authEntity <- makeAuthEntity userEntityIdField (userEntityName, userEntity) authIdentityEntity <- makeAuthIdentityEntity + sessionEntity <- makeSessionEntity let entitiesWithAuth = injectAuthIntoUserEntity userEntityName entities - return $ entitiesWithAuth ++ [authEntity, authIdentityEntity] + return $ entitiesWithAuth ++ [authEntity, authIdentityEntity, sessionEntity] where -- We validated the AppSpec so we are sure that the user entity has an id field. userEntityIdField = fromJust $ AS.Entity.getIdField userEntity @@ -94,7 +105,7 @@ makeAuthIdentityEntity = case parsePslBody authIdentityPslBody of makeAuthEntity :: Psl.Model.Field -> (String, AS.Entity.Entity) -> Generator (String, AS.Entity.Entity) makeAuthEntity userEntityIdField (userEntityName, _) = case parsePslBody authEntityPslBody of - Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err + Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating " ++ authEntityName ++ " entity: " ++ show err Right pslBody -> return (authEntityName, AS.Entity.makeEntity pslBody) where authEntityPslBody = @@ -104,6 +115,7 @@ makeAuthEntity userEntityIdField (userEntityName, _) = case parsePslBody authEnt userId ${userEntityIdTypeText}? @unique ${userFieldOnAuthEntityNameText} ${userEntityNameText}? @relation(fields: [userId], references: [${userEntityIdFieldName}], onDelete: Cascade) ${identitiesFieldOnAuthEntityNameText} ${authIdentityEntityNameText}[] + ${sessionsFieldOnAuthEntityNameText} ${sessionEntityNameText}[] |] authEntityIdTypeText = T.pack authEntityIdType @@ -111,10 +123,35 @@ makeAuthEntity userEntityIdField (userEntityName, _) = case parsePslBody authEnt userFieldOnAuthEntityNameText = T.pack userFieldOnAuthEntityName authIdentityEntityNameText = T.pack authIdentityEntityName identitiesFieldOnAuthEntityNameText = T.pack identitiesFieldOnAuthEntityName + sessionsFieldOnAuthEntityNameText = T.pack sessionsFieldOnAuthEntityName + sessionEntityNameText = T.pack sessionEntityName userEntityIdTypeText = T.pack $ show . Psl.Model.Field._type $ userEntityIdField userEntityIdFieldName = T.pack $ Psl.Model.Field._name userEntityIdField +makeSessionEntity :: Generator (String, AS.Entity.Entity) +makeSessionEntity = case parsePslBody sessionEntityPslBody of + Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating " ++ sessionEntityName ++ " entity: " ++ show err + Right pslBody -> return (sessionEntityName, AS.Entity.makeEntity pslBody) + where + sessionEntityPslBody = + T.unpack + [trimming| + id String @id @unique + expiresAt DateTime + + // Needs to be called `userId` for Lucia to be able to create sessions + userId String + // The relation needs to be named as lowercased entity name, because that's what Lucia expects. + // If the entity is named `Foo`, the relation needs to be named `foo`. + ${authFieldOnSessionEntityNameText} ${authEntityNameText} @relation(references: [id], fields: [userId], onDelete: Cascade) + + @@index([userId]) + |] + + authEntityNameText = T.pack authEntityName + authFieldOnSessionEntityNameText = T.pack authFieldOnSessionEntityName + injectAuthIntoUserEntity :: String -> [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)] injectAuthIntoUserEntity userEntityName entities = let userEntity = fromJust $ lookup userEntityName entities diff --git a/waspc/src/Wasp/Generator/DbGenerator/Common.hs b/waspc/src/Wasp/Generator/DbGenerator/Common.hs index 7fb95ecf7a..116e081d85 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Common.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Common.hs @@ -1,8 +1,5 @@ module Wasp.Generator.DbGenerator.Common ( dbMigrationsDirInDbRootDir, - serverPrismaClientOutputDirEnv, - webAppPrismaClientOutputDirEnv, - prismaClientOutputDirInAppComponentDir, dbSchemaFileFromAppComponentDir, dbRootDirInProjectRootDir, dbSchemaChecksumOnLastDbConcurrenceFileProjectRootDir, @@ -19,13 +16,11 @@ module Wasp.Generator.DbGenerator.Common serverRootDirFromDbRootDir, webAppRootDirFromDbRootDir, dbSchemaFileInProjectRootDir, - prismaClientOutputDirEnvVar, DbSchemaChecksumFile, ) where import StrongPath (Dir, File, File', Path', Rel, reldir, relfile, ()) -import qualified StrongPath as SP import Wasp.Generator.Common (AppComponentRootDir, DbRootDir, ProjectRootDir, ServerRootDir) import Wasp.Generator.Templates (TemplatesDir) import Wasp.Project.Db.Migrations (DbMigrationsDir) @@ -93,22 +88,6 @@ dbSchemaChecksumOnLastGenerateFileInDbRootDir = [relfile|schema.prisma.wasp-gene dbSchemaChecksumOnLastGenerateFileProjectRootDir :: Path' (Rel ProjectRootDir) (File DbSchemaChecksumOnLastGenerateFile) dbSchemaChecksumOnLastGenerateFileProjectRootDir = dbRootDirInProjectRootDir dbSchemaChecksumOnLastGenerateFileInDbRootDir -prismaClientOutputDirEnvVar :: String -prismaClientOutputDirEnvVar = "PRISMA_CLIENT_OUTPUT_DIR" - -prismaClientOutputDirInAppComponentDir :: AppComponentRootDir d => Path' (Rel d) (Dir ServerRootDir) -prismaClientOutputDirInAppComponentDir = [reldir|node_modules/.prisma/client|] - -serverPrismaClientOutputDirEnv :: (String, String) -serverPrismaClientOutputDirEnv = appComponentPrismaClientOutputDirEnv serverRootDirFromDbRootDir - -webAppPrismaClientOutputDirEnv :: (String, String) -webAppPrismaClientOutputDirEnv = appComponentPrismaClientOutputDirEnv webAppRootDirFromDbRootDir - -appComponentPrismaClientOutputDirEnv :: AppComponentRootDir d => Path' (Rel DbRootDir) (Dir d) -> (String, String) -appComponentPrismaClientOutputDirEnv appComponentDirFromDbRootDir = - (prismaClientOutputDirEnvVar, SP.fromRelDir $ appComponentDirFromDbRootDir prismaClientOutputDirInAppComponentDir) - data MigrateArgs = MigrateArgs { _migrationName :: Maybe String, _isCreateOnlyMigration :: Bool diff --git a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs index 9c4aec1849..a60a9db0f3 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs @@ -13,24 +13,20 @@ module Wasp.Generator.DbGenerator.Jobs ) where -import StrongPath (Abs, Dir, File, File', Path', ()) +import StrongPath (Abs, Dir, File', Path', ()) import qualified StrongPath as SP import StrongPath.TH (relfile) import qualified System.Info import Wasp.Generator.Common (ProjectRootDir) -import Wasp.Generator.DbGenerator.Common - ( MigrateArgs (..), - PrismaDbSchema, - dbSchemaFileInProjectRootDir, - ) -import Wasp.Generator.Job (JobType) +import Wasp.Generator.DbGenerator.Common (MigrateArgs (..), dbSchemaFileInProjectRootDir) import qualified Wasp.Generator.Job as J import Wasp.Generator.Job.Process (runNodeCommandAsJob, runNodeCommandAsJobWithExtraEnv) import Wasp.Generator.ServerGenerator.Common (serverRootDirInProjectRootDir) import Wasp.Generator.ServerGenerator.Db.Seed (dbSeedNameEnvVarName) +import Wasp.Project.Common (WaspProjectDir, waspProjectDirFromProjectRootDir) migrateDev :: Path' Abs (Dir ProjectRootDir) -> MigrateArgs -> J.Job -migrateDev projectDir migrateArgs = +migrateDev projectRootDir migrateArgs = -- NOTE(matija): We are running this command from server's root dir since that is where -- Prisma packages (cli and client) are currently installed. -- NOTE(martin): `prisma migrate dev` refuses to execute when interactivity is needed if stdout is being piped, @@ -40,8 +36,8 @@ migrateDev projectDir migrateArgs = -- we are using `script` to trick Prisma into thinking it is running in TTY (interactively). runNodeCommandAsJob serverDir "script" scriptArgs J.Db where - serverDir = projectDir serverRootDirInProjectRootDir - schemaFile = projectDir dbSchemaFileInProjectRootDir + serverDir = projectRootDir serverRootDirInProjectRootDir + schemaFile = projectRootDir dbSchemaFileInProjectRootDir scriptArgs = if System.Info.os == "darwin" @@ -57,7 +53,7 @@ migrateDev projectDir migrateArgs = -- * Linux - we are passing the command as one argument, so we MUST quote the paths. buildPrismaMigrateCmd :: (String -> String) -> [String] buildPrismaMigrateCmd argQuoter = - [ argQuoter $ absPrismaExecutableFp projectDir, + [ argQuoter $ absPrismaExecutableFp (projectRootDir waspProjectDirFromProjectRootDir), "migrate", "dev", "--schema", @@ -85,15 +81,19 @@ asPrismaCliArgs migrateArgs = do -- Because of the --exit-code flag, it changes the exit code behavior -- to signal if the diff is empty or not (Empty: 0, Error: 1, Not empty: 2) migrateDiff :: Path' Abs (Dir ProjectRootDir) -> J.Job -migrateDiff projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> - [ "migrate", - "diff", - "--from-schema-datamodel", - SP.fromAbsFile schema, - "--to-schema-datasource", - SP.fromAbsFile schema, - "--exit-code" - ] +migrateDiff projectRootDir = + runPrismaCommandAsJob + projectRootDir + [ "migrate", + "diff", + "--from-schema-datamodel", + SP.fromAbsFile schema, + "--to-schema-datasource", + SP.fromAbsFile schema, + "--exit-code" + ] + where + schema = projectRootDir dbSchemaFileInProjectRootDir -- | Checks to see if all migrations are applied to the DB. -- An exit code of 0 means we successfully verified all migrations are applied. @@ -101,27 +101,34 @@ migrateDiff projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> -- or (b) there are pending migrations to apply. -- Therefore, this should be checked **after** a command that ensures connectivity. migrateStatus :: Path' Abs (Dir ProjectRootDir) -> J.Job -migrateStatus projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> - ["migrate", "status", "--schema", SP.fromAbsFile schema] +migrateStatus projectRootDir = + runPrismaCommandAsJob + projectRootDir + ["migrate", "status", "--schema", SP.fromAbsFile schema] + where + schema = projectRootDir dbSchemaFileInProjectRootDir -- | Runs `prisma migrate reset`, which drops the tables (so schemas and data is lost) and then -- reapplies all the migrations. reset :: Path' Abs (Dir ProjectRootDir) -> J.Job -reset projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> - -- NOTE(martin): We do "--skip-seed" here because I just think seeding happening automatically on - -- reset is too aggressive / confusing. - ["migrate", "reset", "--schema", SP.fromAbsFile schema, "--skip-generate", "--skip-seed"] +reset projectRootDir = + runPrismaCommandAsJob + projectRootDir + -- NOTE(martin): We do "--skip-seed" here because I just think seeding happening automatically on + -- reset is too aggressive / confusing. + ["migrate", "reset", "--schema", SP.fromAbsFile schema, "--skip-generate", "--skip-seed"] + where + schema = projectRootDir dbSchemaFileInProjectRootDir -- | Runs `prisma db seed`, which executes the seeding script specified in package.json in -- prisma.seed field. seed :: Path' Abs (Dir ProjectRootDir) -> String -> J.Job -- NOTE: Since v 0.3, Prisma doesn't use --schema parameter for `db seed`. -seed projectDir seedName = +seed projectRootDir seedName = runPrismaCommandAsJobWithExtraEnv - J.Db [(dbSeedNameEnvVarName, seedName)] - projectDir - (const ["db", "seed"]) + projectRootDir + ["db", "seed"] -- | Checks if the DB is running and connectable by running -- `prisma db execute --stdin --schema `. @@ -130,56 +137,43 @@ seed projectDir seedName = -- Since nothing is passed to stdin, `prisma db execute` just runs an empty -- SQL command, which works perfectly for checking if the database is running. dbExecuteTest :: Path' Abs (Dir ProjectRootDir) -> J.Job -dbExecuteTest projectDir = - let absSchemaPath = projectDir dbSchemaFileInProjectRootDir - in runPrismaCommandAsDbJob - projectDir - (const ["db", "execute", "--stdin", "--schema", SP.fromAbsFile absSchemaPath]) +dbExecuteTest projectRootDir = + runPrismaCommandAsJob projectRootDir ["db", "execute", "--stdin", "--schema", SP.fromAbsFile schema] + where + schema = projectRootDir dbSchemaFileInProjectRootDir -- | Runs `prisma studio` - Prisma's db inspector. runStudio :: Path' Abs (Dir ProjectRootDir) -> J.Job -runStudio projectDir = runPrismaCommandAsDbJob projectDir $ \schema -> - ["studio", "--schema", SP.fromAbsFile schema] - -generatePrismaClient :: Path' Abs (Dir ProjectRootDir) -> (String, String) -> JobType -> J.Job -generatePrismaClient projectDir prismaClientOutputDirEnv jobType = - runPrismaCommandAsJobWithExtraEnv jobType envVars projectDir $ \schema -> - ["generate", "--schema", SP.fromAbsFile schema] +runStudio projectRootDir = + runPrismaCommandAsJob projectRootDir ["studio", "--schema", SP.fromAbsFile schema] where - envVars = [prismaClientOutputDirEnv] + schema = projectRootDir dbSchemaFileInProjectRootDir -runPrismaCommandAsDbJob :: - Path' Abs (Dir ProjectRootDir) -> - (Path' Abs (File PrismaDbSchema) -> [String]) -> - J.Job -runPrismaCommandAsDbJob projectDir makeCmdArgs = - runPrismaCommandAsJob J.Db projectDir makeCmdArgs +generatePrismaClient :: Path' Abs (Dir ProjectRootDir) -> J.Job +generatePrismaClient projectRootDir = + runPrismaCommandAsJob projectRootDir ["generate", "--schema", SP.fromAbsFile schema] + where + schema = projectRootDir dbSchemaFileInProjectRootDir -runPrismaCommandAsJob :: - JobType -> - Path' Abs (Dir ProjectRootDir) -> - (Path' Abs (File PrismaDbSchema) -> [String]) -> - J.Job -runPrismaCommandAsJob jobType projectDir makeCmdArgs = - runPrismaCommandAsJobWithExtraEnv jobType [] projectDir makeCmdArgs +runPrismaCommandAsJob :: Path' Abs (Dir ProjectRootDir) -> [String] -> J.Job +runPrismaCommandAsJob projectRootDir cmdArgs = + runPrismaCommandAsJobWithExtraEnv [] projectRootDir cmdArgs runPrismaCommandAsJobWithExtraEnv :: - JobType -> [(String, String)] -> Path' Abs (Dir ProjectRootDir) -> - (Path' Abs (File PrismaDbSchema) -> [String]) -> + [String] -> J.Job -runPrismaCommandAsJobWithExtraEnv jobType envVars projectDir makeCmdArgs = - runNodeCommandAsJobWithExtraEnv envVars serverDir (absPrismaExecutableFp projectDir) (makeCmdArgs schemaFile) jobType +runPrismaCommandAsJobWithExtraEnv envVars projectRootDir cmdArgs = + runNodeCommandAsJobWithExtraEnv envVars waspProjectDir (absPrismaExecutableFp waspProjectDir) cmdArgs J.Db where - serverDir = projectDir serverRootDirInProjectRootDir - schemaFile = projectDir dbSchemaFileInProjectRootDir + waspProjectDir = projectRootDir waspProjectDirFromProjectRootDir -- | NOTE: The expectation is that `npm install` was already executed -- such that we can use the locally installed package. -- This assumption is ok since it happens during compilation now. -absPrismaExecutableFp :: Path' Abs (Dir ProjectRootDir) -> FilePath -absPrismaExecutableFp projectDir = SP.fromAbsFile prismaExecutableAbs +absPrismaExecutableFp :: Path' Abs (Dir WaspProjectDir) -> FilePath +absPrismaExecutableFp waspProjectDir = SP.fromAbsFile prismaExecutableAbs where prismaExecutableAbs :: Path' Abs File' - prismaExecutableAbs = projectDir serverRootDirInProjectRootDir [relfile|./node_modules/.bin/prisma|] + prismaExecutableAbs = waspProjectDir [relfile|./node_modules/.bin/prisma|] diff --git a/waspc/src/Wasp/Generator/DbGenerator/Operations.hs b/waspc/src/Wasp/Generator/DbGenerator/Operations.hs index eea120200b..503e912938 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Operations.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Operations.hs @@ -1,6 +1,6 @@ module Wasp.Generator.DbGenerator.Operations ( migrateDevAndCopyToSource, - generatePrismaClients, + generatePrismaClient, doesSchemaMatchDb, writeDbSchemaChecksumToFile, areAllMigrationsAppliedToDb, @@ -12,13 +12,10 @@ module Wasp.Generator.DbGenerator.Operations ) where -import Control.Applicative (liftA2) import Control.Concurrent (newChan) import Control.Concurrent.Async (concurrently) -import Control.Monad (when) import Control.Monad.Catch (catch) import Control.Monad.Extra (whenM) -import Data.Either (isRight) import qualified Data.Text as T import qualified Path as P import StrongPath (Abs, Dir, File, Path', Rel, ()) @@ -36,12 +33,9 @@ import Wasp.Generator.DbGenerator.Common dbSchemaChecksumOnLastGenerateFileProjectRootDir, dbSchemaFileInProjectRootDir, getOnLastDbConcurrenceChecksumFileRefreshAction, - serverPrismaClientOutputDirEnv, - webAppPrismaClientOutputDirEnv, ) import qualified Wasp.Generator.DbGenerator.Jobs as DbJobs import Wasp.Generator.FileDraft.WriteableMonad (WriteableMonad (copyDirectoryRecursive, doesDirectoryExist)) -import qualified Wasp.Generator.Job as J import Wasp.Generator.Job.IO ( collectJobTextOutputUntilExitReceived, printJobMsgsUntilExitReceived, @@ -184,31 +178,21 @@ isDbConnectionPossible DbConnectionSuccess = True isDbConnectionPossible DbNotCreated = True isDbConnectionPossible _ = False -generatePrismaClients :: Path' Abs (Dir ProjectRootDir) -> IO (Either String ()) -generatePrismaClients projectRootDir = do - generateResult <- liftA2 (>>) generatePrismaClientForServer generatePrismaClientForWebApp projectRootDir - when (isRight generateResult) updateDbSchemaChecksumOnLastGenerate - return generateResult - where - generatePrismaClientForServer = generatePrismaClient serverPrismaClientOutputDirEnv J.Server - generatePrismaClientForWebApp = generatePrismaClient webAppPrismaClientOutputDirEnv J.WebApp - updateDbSchemaChecksumOnLastGenerate = - writeDbSchemaChecksumToFile projectRootDir dbSchemaChecksumOnLastGenerateFileProjectRootDir - -generatePrismaClient :: - (String, String) -> - J.JobType -> - Path' Abs (Dir ProjectRootDir) -> - IO (Either String ()) -generatePrismaClient prismaClientOutputDirEnv jobType projectRootDir = do +generatePrismaClient :: Path' Abs (Dir ProjectRootDir) -> IO (Either String ()) +generatePrismaClient projectRootDir = do chan <- newChan (_, exitCode) <- concurrently (readJobMessagesAndPrintThemPrefixed chan) - (DbJobs.generatePrismaClient projectRootDir prismaClientOutputDirEnv jobType chan) - return $ case exitCode of - ExitSuccess -> Right () - ExitFailure code -> Left $ "Prisma client generation failed with exit code: " ++ show code + (DbJobs.generatePrismaClient projectRootDir chan) + case exitCode of + ExitFailure code -> return $ Left $ "Prisma client generation failed with exit code: " ++ show code + ExitSuccess -> do + updateDbSchemaChecksumOnLastGenerate + return $ Right () + where + updateDbSchemaChecksumOnLastGenerate = + writeDbSchemaChecksumToFile projectRootDir dbSchemaChecksumOnLastGenerateFileProjectRootDir -- | Checks `prisma migrate diff` exit code to determine if schema.prisma is -- different than the DB. Returns Nothing on error as we do not know the current state. diff --git a/waspc/src/Wasp/Generator/DockerGenerator.hs b/waspc/src/Wasp/Generator/DockerGenerator.hs index 5da61a3b4c..51b327e64d 100644 --- a/waspc/src/Wasp/Generator/DockerGenerator.hs +++ b/waspc/src/Wasp/Generator/DockerGenerator.hs @@ -24,14 +24,12 @@ import Wasp.Generator.Common import Wasp.Generator.DbGenerator.Common ( PrismaDbSchema, dbSchemaFileFromAppComponentDir, - serverPrismaClientOutputDirEnv, ) import Wasp.Generator.FileDraft (FileDraft (..), createTemplateFileDraft) import qualified Wasp.Generator.FileDraft.TemplateFileDraft as TmplFD import Wasp.Generator.Monad (Generator, GeneratorError, runGenerator) import Wasp.Generator.Templates (TemplatesDir, compileAndRenderTemplate) import qualified Wasp.SemanticVersion as SV -import Wasp.Util (getEnvVarDefinition) genDockerFiles :: AppSpec -> Generator [FileDraft] genDockerFiles spec = sequence [genDockerfile spec, genDockerignore spec] @@ -47,7 +45,6 @@ genDockerfile spec = do ( Just $ object [ "usingPrisma" .= not (null $ AS.getDecls @AS.Entity.Entity spec), - "serverPrismaClientOutputDirEnv" .= getEnvVarDefinition serverPrismaClientOutputDirEnv, "dbSchemaFileFromServerDir" .= SP.fromRelFile dbSchemaFileFromServerDir, "nodeMajorVersion" .= show (SV.major $ getLowestNodeVersionUserAllows spec), "userDockerfile" .= fromMaybe "" (AS.userDockerfileContents spec) diff --git a/waspc/src/Wasp/Generator/ExternalCodeGenerator.hs b/waspc/src/Wasp/Generator/ExternalCodeGenerator.hs deleted file mode 100644 index f2875db8eb..0000000000 --- a/waspc/src/Wasp/Generator/ExternalCodeGenerator.hs +++ /dev/null @@ -1,38 +0,0 @@ -module Wasp.Generator.ExternalCodeGenerator - ( genExternalCodeDir, - ) -where - -import Data.Maybe (mapMaybe) -import qualified StrongPath as SP -import qualified System.FilePath as FP -import qualified Wasp.AppSpec.ExternalCode as EC -import qualified Wasp.Generator.ExternalCodeGenerator.Common as C -import Wasp.Generator.ExternalCodeGenerator.Js (genSourceFile) -import Wasp.Generator.FileDraft (FileDraft) -import qualified Wasp.Generator.FileDraft as FD -import Wasp.Generator.Monad (Generator) - --- | Takes external code files from Wasp and generates them in new location as part of the generated project. --- It might not just copy them but also do some changes on them, as needed. -genExternalCodeDir :: - C.ExternalCodeGeneratorStrategy -> - [EC.File] -> - Generator [FD.FileDraft] -genExternalCodeDir strategy = sequence . mapMaybe (genFile strategy) - -genFile :: C.ExternalCodeGeneratorStrategy -> EC.File -> Maybe (Generator FD.FileDraft) -genFile strategy file - | fileName == "tsconfig.json" = Nothing - | extension `elem` [".js", ".jsx", ".ts", ".tsx"] = Just $ genSourceFile strategy file - | otherwise = Just $ genResourceFile strategy file - where - extension = FP.takeExtension filePath - fileName = FP.takeFileName filePath - filePath = SP.toFilePath $ EC.filePathInExtCodeDir file - -genResourceFile :: C.ExternalCodeGeneratorStrategy -> EC.File -> Generator FileDraft -genResourceFile strategy file = return $ FD.createCopyFileDraft relDstPath absSrcPath - where - relDstPath = C._resolveDstFilePath strategy $ EC.filePathInExtCodeDir file - absSrcPath = EC.fileAbsPath file diff --git a/waspc/src/Wasp/Generator/ExternalCodeGenerator/Common.hs b/waspc/src/Wasp/Generator/ExternalCodeGenerator/Common.hs index 184f7dc435..e8d44e3f8a 100644 --- a/waspc/src/Wasp/Generator/ExternalCodeGenerator/Common.hs +++ b/waspc/src/Wasp/Generator/ExternalCodeGenerator/Common.hs @@ -1,17 +1,9 @@ module Wasp.Generator.ExternalCodeGenerator.Common - ( ExternalCodeGeneratorStrategy (..), - GeneratedExternalCodeDir, - castRelPathFromSrcToGenExtCodeDir, - asGenExtFile, + ( GeneratedExternalCodeDir, ) where -import Data.Text (Text) -import StrongPath (File', Path', Rel) -import qualified StrongPath as SP -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) -import Wasp.Generator.Common (ProjectRootDir) - +-- todo(filip): Where should I put this? -- TODO: consider refactoring the usage of GeneratedExternalCodeDir since -- generated code might end up in multiple places (e.g. ext-src/ but also public/). -- Name should probably be narrowed down to something that represent only the ext-src/ @@ -19,17 +11,3 @@ import Wasp.Generator.Common (ProjectRootDir) -- | Path to the directory where ext code will be generated. data GeneratedExternalCodeDir - -asGenExtFile :: Path' (Rel d) File' -> Path' (Rel GeneratedExternalCodeDir) File' -asGenExtFile = SP.castRel - -castRelPathFromSrcToGenExtCodeDir :: Path' (Rel SourceExternalCodeDir) a -> Path' (Rel GeneratedExternalCodeDir) a -castRelPathFromSrcToGenExtCodeDir = SP.castRel - -data ExternalCodeGeneratorStrategy = ExternalCodeGeneratorStrategy - { -- | Takes a path where the external code js file will be generated. - -- Also takes text of the file. Returns text where special @wasp imports have been replaced with - -- imports that will work. - _resolveJsFileWaspImports :: Path' (Rel GeneratedExternalCodeDir) File' -> Text -> Text, - _resolveDstFilePath :: Path' (Rel SourceExternalCodeDir) File' -> Path' (Rel ProjectRootDir) File' - } diff --git a/waspc/src/Wasp/Generator/ExternalCodeGenerator/Js.hs b/waspc/src/Wasp/Generator/ExternalCodeGenerator/Js.hs deleted file mode 100644 index 3378ca1b01..0000000000 --- a/waspc/src/Wasp/Generator/ExternalCodeGenerator/Js.hs +++ /dev/null @@ -1,49 +0,0 @@ -module Wasp.Generator.ExternalCodeGenerator.Js - ( genSourceFile, - resolveJsFileWaspImportsForExtCodeDir, - ) -where - -import Data.Maybe (fromJust) -import Data.Text (Text, unpack) -import qualified Data.Text as T -import FilePath.Extra (reversePosixPath) -import StrongPath (Dir, File', Path', Rel, ()) -import qualified StrongPath as SP -import qualified Text.Regex.TDFA as TR -import qualified Wasp.AppSpec.ExternalCode as EC -import Wasp.Generator.ExternalCodeGenerator.Common (GeneratedExternalCodeDir) -import qualified Wasp.Generator.ExternalCodeGenerator.Common as C -import qualified Wasp.Generator.FileDraft as FD -import Wasp.Generator.Monad (Generator) - -genSourceFile :: C.ExternalCodeGeneratorStrategy -> EC.File -> Generator FD.FileDraft -genSourceFile strategy file = return $ FD.createTextFileDraft dstPath text' - where - filePathInSrcExtCodeDir = EC.filePathInExtCodeDir file - - filePathInGenExtCodeDir :: Path' (Rel C.GeneratedExternalCodeDir) File' - filePathInGenExtCodeDir = C.castRelPathFromSrcToGenExtCodeDir filePathInSrcExtCodeDir - - text = EC.fileText file - text' = C._resolveJsFileWaspImports strategy filePathInGenExtCodeDir text - dstPath = C._resolveDstFilePath strategy filePathInSrcExtCodeDir - --- | Replaces imports that start with "@wasp/" with imports that start from the src dir of the app. -resolveJsFileWaspImportsForExtCodeDir :: - -- | Relative path of ext code dir in src dir of app (web app, server (app), ...) - Path' (Rel ()) (Dir GeneratedExternalCodeDir) -> - -- | Path where this JS file will be generated. - Path' (Rel GeneratedExternalCodeDir) File' -> - -- | Original text of the file. - Text -> - -- | Text of the file with special "@wasp" imports resolved (replaced with normal JS imports). - Text -resolveJsFileWaspImportsForExtCodeDir extCodeDirInAppSrcDir jsFileDstPathInExtCodeDir jsFileText = - let matches = concat (unpack jsFileText TR.=~ ("(from +['\"]@wasp/)" :: String) :: [[String]]) - in foldr replaceFromWasp jsFileText matches - where - replaceFromWasp fromWasp = T.replace (T.pack fromWasp) (T.pack $ transformFromWasp fromWasp) - transformFromWasp fromWasp = reverse (drop (length ("@wasp/" :: String)) $ reverse fromWasp) ++ pathPrefix ++ "/" - pathPrefix = reversePosixPath $ SP.fromRelDirP $ fromJust $ SP.relDirToPosix $ SP.parent jsFileDstPathInAppSrcDir - jsFileDstPathInAppSrcDir = extCodeDirInAppSrcDir jsFileDstPathInExtCodeDir diff --git a/waspc/src/Wasp/Generator/Job.hs b/waspc/src/Wasp/Generator/Job.hs index f4859eb118..45730c29a2 100644 --- a/waspc/src/Wasp/Generator/Job.hs +++ b/waspc/src/Wasp/Generator/Job.hs @@ -28,4 +28,4 @@ data JobMessageData data JobOutputType = Stdout | Stderr deriving (Show, Eq) -data JobType = WebApp | Server | Db deriving (Show, Eq, Ord, Bounded, Enum) +data JobType = WebApp | Server | Db | Wasp deriving (Show, Eq, Ord, Bounded, Enum) diff --git a/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs b/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs index e0fbe83fab..d3f3bd0d97 100644 --- a/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs +++ b/waspc/src/Wasp/Generator/Job/IO/PrefixedWriter.hs @@ -1,4 +1,5 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE TupleSections #-} module Wasp.Generator.Job.IO.PrefixedWriter ( printJobMessagePrefixed, @@ -10,10 +11,13 @@ where import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.State (get, put) import Control.Monad.State.Strict (MonadState, StateT, runStateT) +import Data.List (maximumBy) +import Data.Ord (comparing) import qualified Data.Set as S import qualified Data.Text as T import qualified Data.Text.IO as T.IO import System.IO (hFlush, stderr) +import Wasp.Generator.Job (JobType) import qualified Wasp.Generator.Job as J import Wasp.Generator.Job.Common (getJobMessageContent, getJobMessageOutHandle) import qualified Wasp.Util.Terminal as Term @@ -169,30 +173,44 @@ getJobMessageOutput jm = makeJobMessagePrefix :: J.JobMessage -> T.Text makeJobMessagePrefix jobMsg = - (T.pack . buildPrefix . concat) - [ [("[", jobStyles)], + T.pack . concatMap (\(text, styles) -> Term.applyStyles styles text) . concat $ + [ [(startDelimiter, jobStyles)], + [unstyled namePaddingFront], [(jobName, jobStyles)], + [unstyled namePaddingBack], styledFlags, - [("]", jobStyles)] + [(endDelimiter, jobStyles)], + [unstyled " "] ] where - buildPrefix :: [StyledText] -> String - buildPrefix styledTexts = - concatMap styledTextToTermText styledTexts <> replicate paddingLength ' ' + (namePaddingFront, namePaddingBack) = + ( replicate namePaddingLengthFront ' ', + replicate namePaddingLengthBack ' ' + ) where - numVisibleChars = length $ concatMap fst styledTexts + namePaddingLengthFront = paddingLength `div` 2 + namePaddingLengthBack = paddingLength `div` 2 + paddingLength `mod` 2 - length (concatMap fst styledFlags) paddingLength = max 0 (minPrefixLength - numVisibleChars) - styledTextToTermText (text, styles) = Term.applyStyles styles text - -- NOTE: Adjust this number if you expect longer prefixes! - minPrefixLength = 10 + numVisibleChars = length . concat $ [startDelimiter, jobName, endDelimiter] + minPrefixLength = length $ startDelimiter <> " " <> longestJobName <> " " <> endDelimiter + longestJobName = + maximumBy (comparing length) $ + fst . getJobNameAndStyles <$> [(minBound :: JobType) .. maxBound] - (jobName, jobStyles) = case J._jobType jobMsg of + (startDelimiter, endDelimiter) = ("[", "]") + + styledFlags :: [StyledText] + styledFlags = + [("!", [Term.Red, Term.Bold]) | getJobMessageOutHandle jobMsg == stderr] + + (jobName, jobStyles) = getJobNameAndStyles $ J._jobType jobMsg + + getJobNameAndStyles = \case + J.Wasp -> ("Wasp", [Term.Yellow]) J.Server -> ("Server", [Term.Magenta]) J.WebApp -> ("Client", [Term.Cyan]) J.Db -> ("Db", [Term.Blue]) - styledFlags :: [StyledText] - styledFlags = - [("!", [Term.Red]) | getJobMessageOutHandle jobMsg == stderr] + unstyled = (,[]) type StyledText = (String, [Term.Style]) diff --git a/waspc/src/Wasp/Generator/JsImport.hs b/waspc/src/Wasp/Generator/JsImport.hs index 25db2c7df3..9df2fea89f 100644 --- a/waspc/src/Wasp/Generator/JsImport.hs +++ b/waspc/src/Wasp/Generator/JsImport.hs @@ -1,6 +1,7 @@ module Wasp.Generator.JsImport ( extImportToJsImport, jsImportToImportJson, + extImportNameToJsImportName, ) where @@ -14,6 +15,7 @@ import Wasp.Generator.ExternalCodeGenerator.Common (GeneratedExternalCodeDir) import Wasp.JsImport ( JsImport, JsImportName (JsImportField, JsImportModule), + JsImportPath (RelativeImportPath), getJsImportStmtAndIdentifier, makeJsImport, ) @@ -24,15 +26,15 @@ extImportToJsImport :: Path Posix (Rel importLocation) (Dir d) -> EI.ExtImport -> JsImport -extImportToJsImport pathFromSrcDirToExtCodeDir pathFromImportLocationToSrcDir extImport = makeJsImport importPath importName +extImportToJsImport pathFromSrcDirToExtCodeDir pathFromImportLocationToSrcDir extImport = makeJsImport (RelativeImportPath importPath) importName where userDefinedPathInExtSrcDir = SP.castRel $ EI.path extImport :: Path Posix (Rel GeneratedExternalCodeDir) File' importName = extImportNameToJsImportName $ EI.name extImport importPath = SP.castRel $ pathFromImportLocationToSrcDir pathFromSrcDirToExtCodeDir userDefinedPathInExtSrcDir - extImportNameToJsImportName :: EI.ExtImportName -> JsImportName - extImportNameToJsImportName (EI.ExtImportModule name) = JsImportModule name - extImportNameToJsImportName (EI.ExtImportField name) = JsImportField name +extImportNameToJsImportName :: EI.ExtImportName -> JsImportName +extImportNameToJsImportName (EI.ExtImportModule name) = JsImportModule name +extImportNameToJsImportName (EI.ExtImportField name) = JsImportField name jsImportToImportJson :: Maybe JsImport -> Aeson.Value jsImportToImportJson maybeJsImport = maybe notDefinedValue mkTmplData maybeJsImport diff --git a/waspc/src/Wasp/Generator/NpmDependencies.hs b/waspc/src/Wasp/Generator/NpmDependencies.hs index cb6015d45a..faaed68adb 100644 --- a/waspc/src/Wasp/Generator/NpmDependencies.hs +++ b/waspc/src/Wasp/Generator/NpmDependencies.hs @@ -19,13 +19,12 @@ where import Data.Aeson import Data.List (intercalate, sort) import qualified Data.Map as Map -import Data.Maybe (fromMaybe) import qualified Data.Maybe as Maybe import GHC.Generics import Wasp.AppSpec (AppSpec) -import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App.Dependency as D -import qualified Wasp.AppSpec.Valid as ASV +import qualified Wasp.AppSpec.PackageJson as AS.PackageJson import Wasp.Generator.Monad (Generator, GeneratorError (..), logAndThrowGeneratorError) data NpmDepsForFullStack = NpmDepsForFullStack @@ -108,9 +107,9 @@ buildNpmDepsForFullStack spec forServer forWebApp = getUserNpmDepsForPackage :: AppSpec -> NpmDepsForUser getUserNpmDepsForPackage spec = NpmDepsForUser - { userDependencies = fromMaybe [] $ AS.App.dependencies $ snd $ ASV.getApp spec, + { userDependencies = AS.PackageJson.getDependencies $ AS.packageJson spec, -- Should we allow user devDependencies? https://github.com/wasp-lang/wasp/issues/456 - userDevDependencies = [] + userDevDependencies = AS.PackageJson.getDevDependencies $ AS.packageJson spec } conflictErrorToMessage :: DependencyConflictError -> String @@ -141,9 +140,11 @@ combineNpmDepsForPackage npmDepsForWasp npmDepsForUser = if null conflictErrors && null devConflictErrors then Right $ + -- todo(filip): check whether dependency updates and npm install work properly + -- todo(filip): reconsider whether we want to change the {sever,web-app}/package.json dynamically NpmDepsForPackage - { dependencies = waspDependencies npmDepsForWasp ++ remainingUserDeps, - devDependencies = waspDevDependencies npmDepsForWasp ++ remainingUserDevDeps + { dependencies = Map.elems remainingWapsDeps, + devDependencies = Map.elems remainingWaspDevDeps } else Left $ @@ -159,8 +160,8 @@ combineNpmDepsForPackage npmDepsForWasp npmDepsForUser = allWaspDepsByName = waspDepsByName `Map.union` waspDevDepsByName conflictErrors = determineConflictErrors allWaspDepsByName userDepsByName devConflictErrors = determineConflictErrors allWaspDepsByName userDevDepsByName - remainingUserDeps = getRemainingUserDeps allWaspDepsByName userDepsByName - remainingUserDevDeps = getRemainingUserDeps allWaspDepsByName userDevDepsByName + remainingWapsDeps = waspDepsByName `Map.difference` userDepsByName + remainingWaspDevDeps = waspDevDepsByName `Map.difference` userDevDepsByName type DepsByName = Map.Map String D.Dependency @@ -179,12 +180,6 @@ determineConflictErrors waspDepsByName userDepsByName = then Just $ DependencyConflictError waspDep userDep else Nothing --- Given a map of wasp dependencies and a map of user dependencies, construct a --- a list of user dependencies that remain once any overlapping wasp dependencies --- have been removed. This assumes conflict detection was already passed. -getRemainingUserDeps :: DepsByName -> DepsByName -> [D.Dependency] -getRemainingUserDeps waspDepsByName userDepsByName = Map.elems $ userDepsByName `Map.difference` waspDepsByName - -- Construct a map of dependency keyed by dependency name. makeDepsByName :: [D.Dependency] -> DepsByName makeDepsByName = Map.fromList . fmap (\d -> (D.name d, d)) diff --git a/waspc/src/Wasp/Generator/NpmInstall.hs b/waspc/src/Wasp/Generator/NpmInstall.hs index ec837a3884..bc51e896b3 100644 --- a/waspc/src/Wasp/Generator/NpmInstall.hs +++ b/waspc/src/Wasp/Generator/NpmInstall.hs @@ -4,26 +4,29 @@ module Wasp.Generator.NpmInstall ) where -import Control.Concurrent (Chan, newChan, readChan) +import Control.Concurrent (Chan, newChan, readChan, threadDelay, writeChan) import Control.Concurrent.Async (concurrently) import Control.Monad (when) import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Lazy as B +import qualified Data.Text as T import StrongPath (Abs, Dir, File', Path', Rel, relfile, ()) import qualified StrongPath as SP import System.Directory (doesFileExist, removeFile) import System.Exit (ExitCode (..)) +import UnliftIO (race) import Wasp.AppSpec (AppSpec) import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.Job (Job, JobMessage, JobType) import qualified Wasp.Generator.Job as J import Wasp.Generator.Job.IO.PrefixedWriter (PrefixedWriter, printJobMessagePrefixed, runPrefixedWriter) import Wasp.Generator.Monad (GeneratorError (..), GeneratorWarning (..)) import qualified Wasp.Generator.NpmDependencies as N +import qualified Wasp.Generator.SdkGenerator as SdkGenerator import Wasp.Generator.ServerGenerator as SG -import qualified Wasp.Generator.ServerGenerator.Setup as ServerSetup import Wasp.Generator.WebAppGenerator as WG -import qualified Wasp.Generator.WebAppGenerator.Setup as WebAppSetup +import Wasp.Project.Common (WaspProjectDir) -- | Figure out if npm install is needed. -- @@ -59,14 +62,18 @@ isNpmInstallNeeded spec dstDir = do -- Run npm install for desired AppSpec dependencies, recording what we installed -- Installation may fail, in which the installation record is removed. -installNpmDependenciesWithInstallRecord :: N.NpmDepsForFullStack -> Path' Abs (Dir ProjectRootDir) -> IO ([GeneratorWarning], [GeneratorError]) -installNpmDependenciesWithInstallRecord npmDepsForFullStack dstDir = do +installNpmDependenciesWithInstallRecord :: + N.NpmDepsForFullStack -> + Path' Abs (Dir WaspProjectDir) -> + Path' Abs (Dir ProjectRootDir) -> + IO ([GeneratorWarning], [GeneratorError]) +installNpmDependenciesWithInstallRecord npmDepsForFullStack waspProjectDir dstDir = do -- in case anything fails during installation that would leave node modules in -- a broken state, we remove the file before we start npm install fileExists <- doesFileExist dependenciesInstalledFp when fileExists $ removeFile dependenciesInstalledFp -- now actually do the installation - npmInstallResult <- installNpmDependencies dstDir + npmInstallResult <- installNpmDependencies waspProjectDir case npmInstallResult of Left npmInstallError -> do return ([], [GenericGeneratorError $ "npm install failed: " ++ npmInstallError]) @@ -100,40 +107,62 @@ loadInstalledFullStackNpmDependencies dstDir = do return (Aeson.decode fileContents :: Maybe N.NpmDepsForFullStack) else return Nothing +reportInstallationProgress :: Chan JobMessage -> JobType -> IO () +reportInstallationProgress chan jobType = reportPeriodically allPossibleMessages + where + reportPeriodically messages = do + threadDelay $ secToMicroSec 5 + writeChan chan $ J.JobMessage {J._data = J.JobOutput (T.append (head messages) "\n") J.Stdout, J._jobType = jobType} + threadDelay $ secToMicroSec 5 + reportPeriodically (if hasLessThan2Elems messages then messages else drop 1 messages) + secToMicroSec = (* 1000000) + hasLessThan2Elems = null . drop 1 + allPossibleMessages = + [ "Still installing npm dependencies!", + "Installation going great - we'll get there soon!", + "The installation is taking a while, but we'll get there!", + "Yup, still not done installing.", + "We're getting closer and closer, everything will be installed soon!", + "Still waiting for the installation to finish? You should! We got too far to give up now!", + "You've been waiting so patiently, just wait a little longer (for the installation to finish)..." + ] + +installNpmDependenciesAndReport :: Job -> Chan JobMessage -> JobType -> IO ExitCode +installNpmDependenciesAndReport installJob chan jobType = do + writeChan chan $ J.JobMessage {J._data = J.JobOutput "Starting npm install\n" J.Stdout, J._jobType = jobType} + result <- installJob chan `race` reportInstallationProgress chan jobType + case result of + Left exitCode -> return exitCode + Right _ -> error "This should never happen, reporting installation progress should run forever." + +{- HLINT ignore installNpmDependencies "Redundant <$>" -} + -- Run the individual `npm install` commands for both server and webapp projects -- It runs these concurrently, collects the output produced by these commands -- to pass them along to IO with a prefix -installNpmDependencies :: Path' Abs (Dir ProjectRootDir) -> IO (Either String ()) +installNpmDependencies :: Path' Abs (Dir WaspProjectDir) -> IO (Either String ()) installNpmDependencies projectDir = do - chan <- newChan - let runSetupJobs = - ServerSetup.installNpmDependencies projectDir chan - `concurrently` WebAppSetup.installNpmDependencies projectDir chan - (_, result) <- concurrently (handleJobMessages chan) runSetupJobs - case result of - (ExitSuccess, ExitSuccess) -> return $ Right () - exitCodes -> return $ Left $ setupFailedMessage exitCodes + messagesChan <- newChan + installProjectNpmDependencies messagesChan projectDir >>= \case + ExitFailure code -> return $ Left $ "Project setup failed with exit code " ++ show code ++ "." + _ -> return $ Right () + +installProjectNpmDependencies :: + Chan JobMessage -> SP.Path SP.System Abs (Dir WaspProjectDir) -> IO ExitCode +installProjectNpmDependencies messagesChan projectDir = + snd <$> handleProjectInstallMessages messagesChan `concurrently` installProjectDepsJob where - handleJobMessages = runPrefixedWriter . go (False, False) + installProjectDepsJob = + installNpmDependenciesAndReport + (SdkGenerator.installNpmDependencies projectDir) + messagesChan + J.Wasp + handleProjectInstallMessages :: Chan J.JobMessage -> IO () + handleProjectInstallMessages = runPrefixedWriter . processMessages where - go :: (Bool, Bool) -> Chan J.JobMessage -> PrefixedWriter () - go (True, True) _ = return () - go (isWebAppDone, isServerDone) chan = do + processMessages :: Chan J.JobMessage -> PrefixedWriter () + processMessages chan = do jobMsg <- liftIO $ readChan chan case J._data jobMsg of - J.JobOutput {} -> - printJobMessagePrefixed jobMsg - >> go (isWebAppDone, isServerDone) chan - J.JobExit {} -> case J._jobType jobMsg of - J.WebApp -> go (True, isServerDone) chan - J.Server -> go (isWebAppDone, True) chan - J.Db -> error "This should never happen. No db job should be active." - - setupFailedMessage (serverExitCode, webAppExitCode) = - let serverErrorMessage = case serverExitCode of - ExitFailure code -> " Server setup failed with exit code " ++ show code ++ "." - _ -> "" - webAppErrorMessage = case webAppExitCode of - ExitFailure code -> " Web app setup failed with exit code " ++ show code ++ "." - _ -> "" - in "Setup failed!" ++ serverErrorMessage ++ webAppErrorMessage + J.JobOutput {} -> printJobMessagePrefixed jobMsg >> processMessages chan + J.JobExit {} -> return () diff --git a/waspc/src/Wasp/Generator/SdkGenerator.hs b/waspc/src/Wasp/Generator/SdkGenerator.hs new file mode 100644 index 0000000000..6679eff58f --- /dev/null +++ b/waspc/src/Wasp/Generator/SdkGenerator.hs @@ -0,0 +1,288 @@ +{-# LANGUAGE TypeApplications #-} + +module Wasp.Generator.SdkGenerator + ( genSdk, + installNpmDependencies, + genExternalCodeDir, + buildSdk, + ) +where + +import Control.Concurrent (newChan) +import Control.Concurrent.Async (concurrently) +import Data.Aeson (object) +import Data.Aeson.Types ((.=)) +import Data.Maybe (fromMaybe, isJust, mapMaybe) +import GHC.IO (unsafePerformIO) +import StrongPath +import qualified StrongPath as SP +import System.Exit (ExitCode (..)) +import qualified System.FilePath as FP +import Wasp.AppSpec +import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Auth as AS.App.Auth +import qualified Wasp.AppSpec.App.Dependency as AS.Dependency +import qualified Wasp.AppSpec.Entity as AS.Entity +import qualified Wasp.AppSpec.ExternalFiles as EC +import Wasp.AppSpec.Valid (getLowestNodeVersionUserAllows, isAuthEnabled) +import qualified Wasp.AppSpec.Valid as AS.Valid +import Wasp.Generator.Common (ProjectRootDir, makeJsonWithEntityData, prismaVersion) +import qualified Wasp.Generator.DbGenerator.Auth as DbAuth +import Wasp.Generator.FileDraft (FileDraft, createCopyDirFileDraft) +import qualified Wasp.Generator.FileDraft as FD +import Wasp.Generator.FileDraft.CopyDirFileDraft (CopyDirFileDraftDstDirStrategy (RemoveExistingDstDir)) +import qualified Wasp.Generator.Job as J +import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed) +import Wasp.Generator.Job.Process (runNodeCommandAsJob) +import Wasp.Generator.Monad (Generator) +import qualified Wasp.Generator.NpmDependencies as N +import Wasp.Generator.SdkGenerator.Common (SdkTemplatesDir) +import qualified Wasp.Generator.SdkGenerator.Common as C +import Wasp.Generator.SdkGenerator.ServerOpsGenerator (genOperations) +import qualified Wasp.Generator.ServerGenerator.AuthG as ServerAuthG +import Wasp.Generator.Templates (getTemplatesDirAbsPath) +import qualified Wasp.Generator.WebAppGenerator.Common as WebApp +import qualified Wasp.Node.Version as NodeVersion +import Wasp.Project.Common (WaspProjectDir) +import qualified Wasp.Project.Db as Db +import qualified Wasp.SemanticVersion as SV +import Wasp.Util (toLowerFirst, (<++>)) + +genSdk :: AppSpec -> Generator [FileDraft] +genSdk spec = + genSdkHardcoded + <++> genSdkReal spec + +buildSdk :: Path' Abs (Dir ProjectRootDir) -> IO (Either String ()) +buildSdk projectRootDir = do + chan <- newChan + (_, exitCode) <- + concurrently + (readJobMessagesAndPrintThemPrefixed chan) + (runNodeCommandAsJob dstDir "npx" ["tsc"] J.Wasp chan) + case exitCode of + ExitSuccess -> return $ Right () + ExitFailure code -> return $ Left $ "SDK build failed with exit code: " ++ show code + where + dstDir = projectRootDir C.sdkRootDirInProjectRootDir + +genSdkReal :: AppSpec -> Generator [FileDraft] +genSdkReal spec = + sequence + [ genFileCopy [relfile|api/index.ts|], + genFileCopy [relfile|api/events.ts|], + genFileCopy [relfile|core/config.js|], + genFileCopy [relfile|core/auth.js|], + genFileCopy [relfile|core/storage.ts|], + genFileCopy [relfile|core/stitches.config.js|], + genFileCopy [relfile|core/AuthError.js|], + genFileCopy [relfile|core/HttpError.js|], + genFileCopy [relfile|server/dbClient.ts|], + genServerConfigFile spec, + genTsConfigJson, + genServerUtils spec, + genPackageJson spec + ] + <++> genOperations spec + <++> genUniversalDir + <++> genExternalCodeDir (AS.externalCodeFiles spec) + <++> genTypesAndEntitiesDirs spec + where + genFileCopy = return . C.mkTmplFd + +genSdkHardcoded :: Generator [FileDraft] +genSdkHardcoded = + return + [ copyFolder [reldir|auth|], + copyFolder [reldir|operations|], + copyFolder [reldir|rpc|], + copyFolder [reldir|types|] + ] + where + -- copyFile = C.mkTmplFd + copyFolder :: Path' (Rel SdkTemplatesDir) (Dir d) -> FileDraft + copyFolder modul = + createCopyDirFileDraft + RemoveExistingDstDir + (dstFolder castRel modul) + (srcFolder modul) + dstFolder = C.sdkRootDirInProjectRootDir + srcFolder = absSdkTemplatesDir + absSdkTemplatesDir = unsafePerformIO getTemplatesDirAbsPath C.sdkTemplatesDirInTemplatesDir + +genTypesAndEntitiesDirs :: AppSpec -> Generator [FileDraft] +genTypesAndEntitiesDirs spec = + return + [ entitiesIndexFileDraft, + taggedEntitiesFileDraft, + serializationFileDraft, + typesIndexFileDraft + ] + where + entitiesIndexFileDraft = + C.mkTmplFdWithDstAndData + [relfile|entities/index.ts|] + [relfile|entities/index.ts|] + ( Just $ + object + [ "entities" .= allEntities, + "isAuthEnabled" .= isJust maybeUserEntityName, + "authEntityName" .= DbAuth.authEntityName, + "authIdentityEntityName" .= DbAuth.authIdentityEntityName + ] + ) + taggedEntitiesFileDraft = + C.mkTmplFdWithDstAndData + [relfile|server/_types/taggedEntities.ts|] + [relfile|server/_types/taggedEntities.ts|] + (Just $ object ["entities" .= allEntities]) + serializationFileDraft = + C.mkTmplFd + [relfile|server/_types/serialization.ts|] + typesIndexFileDraft = + C.mkTmplFdWithDstAndData + [relfile|server/_types/index.ts|] + [relfile|server/_types/index.ts|] + ( Just $ + object + [ "entities" .= allEntities, + "isAuthEnabled" .= isJust maybeUserEntityName, + "userEntityName" .= userEntityName, + "authEntityName" .= DbAuth.authEntityName, + "authFieldOnUserEntityName" .= DbAuth.authFieldOnUserEntityName, + "authIdentityEntityName" .= DbAuth.authIdentityEntityName, + "identitiesFieldOnAuthEntityName" .= DbAuth.identitiesFieldOnAuthEntityName, + "userFieldName" .= toLowerFirst userEntityName + ] + ) + userEntityName = fromMaybe "" maybeUserEntityName + allEntities = map (makeJsonWithEntityData . fst) $ AS.getDecls @AS.Entity.Entity spec + maybeUserEntityName = AS.refName . AS.App.Auth.userEntity <$> AS.App.auth (snd $ AS.Valid.getApp spec) + +genPackageJson :: AppSpec -> Generator FileDraft +genPackageJson spec = + return $ + C.mkTmplFdWithDstAndData + [relfile|package.json|] + [relfile|package.json|] + ( Just $ + object + [ "depsChunk" .= N.getDependenciesPackageJsonEntry npmDepsForSdk, + "devDepsChunk" .= N.getDevDependenciesPackageJsonEntry npmDepsForSdk + ] + ) + where + npmDepsForSdk = + N.NpmDepsForPackage + { N.dependencies = + AS.Dependency.fromList + [ ("@prisma/client", show prismaVersion), + ("prisma", show prismaVersion), + ("@tanstack/react-query", "^4.29.0"), + ("axios", "^1.4.0"), + ("express", "~4.18.1"), + ("jsonwebtoken", "^8.5.1"), + ("mitt", "3.0.0"), + ("react", "^18.2.0"), + ("lodash.merge", "^4.6.2"), + ("react-router-dom", "^5.3.3"), + ("react-hook-form", "^7.45.4"), + ("secure-password", "^4.0.0"), + ("superjson", "^1.12.2"), + ("@types/express-serve-static-core", "^4.17.13") + ] + ++ depsRequiredForAuth spec + -- This must be installed in the SDK because it lists prisma/client as a dependency. + -- Installing it inside .wasp/out/server/node_modules would also + -- install prisma/client in the same folder, which would cause our + -- runtime to load the wrong (uninitialized prisma/client) + -- TODO(filip): Find a better way to handle duplicate + -- dependencies: https://github.com/wasp-lang/wasp/issues/1640 + ++ ServerAuthG.depsRequiredByAuth spec, + N.devDependencies = + AS.Dependency.fromList + [ ("@tsconfig/node" <> majorNodeVersionStr, "latest") + ] + } + majorNodeVersionStr = show (SV.major $ getLowestNodeVersionUserAllows spec) + +genServerConfigFile :: AppSpec -> Generator FileDraft +genServerConfigFile spec = return $ C.mkTmplFdWithData relConfigFilePath (Just tmplData) + where + relConfigFilePath = [relfile|server/config.js|] + tmplData = + object + [ "isAuthEnabled" .= isAuthEnabled spec, + "databaseUrlEnvVarName" .= Db.databaseUrlEnvVarName, + "defaultClientUrl" .= WebApp.getDefaultClientUrl spec + ] + +-- todo(filip): remove this duplication, we have almost the same thing in the +-- ServerGenerator. +genTsConfigJson :: Generator FileDraft +genTsConfigJson = do + return $ + C.mkTmplFdWithDstAndData + [relfile|tsconfig.json|] + [relfile|tsconfig.json|] + ( Just $ + object + [ "majorNodeVersion" .= show (SV.major NodeVersion.oldestWaspSupportedNodeVersion) + ] + ) + +depsRequiredForAuth :: AppSpec -> [AS.Dependency.Dependency] +depsRequiredForAuth spec = + [AS.Dependency.make ("@stitches/react", show versionRange) | isAuthEnabled spec] + where + versionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 1 2 8)] + +-- TODO(filip): Figure out where this belongs. Check https://github.com/wasp-lang/wasp/pull/1602#discussion_r1437144166 . +-- Also, fix imports for wasp project. +installNpmDependencies :: Path' Abs (Dir WaspProjectDir) -> J.Job +installNpmDependencies projectDir = + runNodeCommandAsJob projectDir "npm" ["install"] J.Wasp + +-- todo(filip): consider reorganizing/splitting the file. + +-- | Takes external code files from Wasp and generates them in new location as part of the generated project. +-- It might not just copy them but also do some changes on them, as needed. +genExternalCodeDir :: [EC.CodeFile] -> Generator [FileDraft] +genExternalCodeDir = sequence . mapMaybe genFile + +genFile :: EC.CodeFile -> Maybe (Generator FileDraft) +genFile file + | fileName == "tsconfig.json" = Nothing + | extension `elem` [".js", ".jsx", ".ts", ".tsx"] = Just $ genSourceFile file + | otherwise = Just $ genResourceFile file + where + extension = FP.takeExtension filePath + fileName = FP.takeFileName filePath + filePath = SP.toFilePath $ EC.filePathInExtCodeDir file + +genResourceFile :: EC.CodeFile -> Generator FileDraft +genResourceFile file = return $ FD.createCopyFileDraft relDstPath absSrcPath + where + relDstPath = C.sdkRootDirInProjectRootDir C.extSrcDirInSdkRootDir SP.castRel (EC._pathInExtCodeDir file) + absSrcPath = EC.fileAbsPath file + +genSourceFile :: EC.CodeFile -> Generator FD.FileDraft +genSourceFile file = return $ FD.createTextFileDraft relDstPath text + where + filePathInSrcExtCodeDir = EC.filePathInExtCodeDir file + text = EC.fileText file + relDstPath = C.sdkRootDirInProjectRootDir C.extSrcDirInSdkRootDir SP.castRel filePathInSrcExtCodeDir + +genUniversalDir :: Generator [FileDraft] +genUniversalDir = + return + [ C.mkTmplFd [relfile|universal/url.ts|], + C.mkTmplFd [relfile|universal/types.ts|], + C.mkTmplFd [relfile|universal/validators.js|] + ] + +genServerUtils :: AppSpec -> Generator FileDraft +genServerUtils spec = return $ C.mkTmplFdWithData [relfile|server/utils.ts|] (Just tmplData) + where + tmplData = object ["isAuthEnabled" .= (isAuthEnabled spec :: Bool)] \ No newline at end of file diff --git a/waspc/src/Wasp/Generator/SdkGenerator/Common.hs b/waspc/src/Wasp/Generator/SdkGenerator/Common.hs new file mode 100644 index 0000000000..bf64691298 --- /dev/null +++ b/waspc/src/Wasp/Generator/SdkGenerator/Common.hs @@ -0,0 +1,59 @@ +module Wasp.Generator.SdkGenerator.Common where + +import qualified Data.Aeson as Aeson +import Data.Maybe (fromJust) +import StrongPath +import qualified StrongPath as SP +import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.ExternalCodeGenerator.Common (GeneratedExternalCodeDir) +import Wasp.Generator.FileDraft (FileDraft, createTemplateFileDraft) +import Wasp.Generator.Templates (TemplatesDir) + +data SdkRootDir + +data SdkTemplatesDir + +mkTmplFdWithDstAndData :: + Path' (Rel SdkTemplatesDir) File' -> + Path' (Rel SdkRootDir) File' -> + Maybe Aeson.Value -> + FileDraft +mkTmplFdWithDstAndData relSrcPath relDstPath tmplData = + createTemplateFileDraft + (sdkRootDirInProjectRootDir relDstPath) + (sdkTemplatesDirInTemplatesDir relSrcPath) + tmplData + +mkTmplFdWithDst :: Path' (Rel SdkTemplatesDir) File' -> Path' (Rel SdkRootDir) File' -> FileDraft +mkTmplFdWithDst src dst = mkTmplFdWithDstAndData src dst Nothing + +mkTmplFdWithData :: + Path' (Rel SdkTemplatesDir) File' -> + Maybe Aeson.Value -> + FileDraft +mkTmplFdWithData relSrcPath tmplData = mkTmplFdWithDstAndData relSrcPath relDstPath tmplData + where + relDstPath = castRel relSrcPath + +mkTmplFd :: Path' (Rel SdkTemplatesDir) File' -> FileDraft +mkTmplFd path = mkTmplFdWithDst path (SP.castRel path) + +sdkRootDirInProjectRootDir :: Path' (Rel ProjectRootDir) (Dir SdkRootDir) +sdkRootDirInProjectRootDir = [reldir|sdk/wasp|] + +sdkTemplatesDirInTemplatesDir :: Path' (Rel TemplatesDir) (Dir SdkTemplatesDir) +sdkTemplatesDirInTemplatesDir = [reldir|sdk|] + +extSrcDirInSdkRootDir :: Path' (Rel SdkRootDir) (Dir GeneratedExternalCodeDir) +extSrcDirInSdkRootDir = [reldir|ext-src|] + +relDirToRelFileP :: Path Posix (Rel d) Dir' -> Path Posix (Rel d) File' +relDirToRelFileP path = fromJust $ SP.parseRelFileP $ removeTrailingSlash $ SP.fromRelDirP path + where + removeTrailingSlash = reverse . dropWhile (== '/') . reverse + +makeSdkImportPath :: Path Posix (Rel SdkRootDir) File' -> Path Posix (Rel s) File' +makeSdkImportPath path = [reldirP|wasp|] path + +extCodeDirInSdkRootDir :: Path' (Rel SdkRootDir) Dir' +extCodeDirInSdkRootDir = [reldir|ext-src|] diff --git a/waspc/src/Wasp/Generator/SdkGenerator/ServerOpsGenerator.hs b/waspc/src/Wasp/Generator/SdkGenerator/ServerOpsGenerator.hs new file mode 100644 index 0000000000..dad6b7289d --- /dev/null +++ b/waspc/src/Wasp/Generator/SdkGenerator/ServerOpsGenerator.hs @@ -0,0 +1,160 @@ +{-# LANGUAGE TypeApplications #-} + +module Wasp.Generator.SdkGenerator.ServerOpsGenerator where + +import Data.Aeson (object, (.=)) +import qualified Data.Aeson as Aeson +import Data.List (nub) +import Data.Maybe (fromJust, fromMaybe) +import StrongPath (Dir', File', Path', Rel, reldir, relfile, ()) +import qualified StrongPath as SP +import Wasp.AppSpec (AppSpec) +import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.Action as AS.Action +import qualified Wasp.AppSpec.ExtImport as EI +import Wasp.AppSpec.Operation (getName) +import qualified Wasp.AppSpec.Operation as AS.Operation +import qualified Wasp.AppSpec.Query as AS.Query +import Wasp.AppSpec.Valid (isAuthEnabled) +import Wasp.Generator.Common (makeJsonWithEntityData) +import Wasp.Generator.FileDraft (FileDraft) +import qualified Wasp.Generator.JsImport as GJI +import Wasp.Generator.Monad (Generator) +import Wasp.Generator.SdkGenerator.Common (mkTmplFdWithData) +import qualified Wasp.Generator.SdkGenerator.Common as C +import Wasp.JsImport (JsImport (..), JsImportPath (..)) +import qualified Wasp.JsImport as JI +import Wasp.Util (toUpperFirst) + +genOperations :: AppSpec -> Generator [FileDraft] +genOperations spec = + sequence + [ genQueryTypesFile spec, + genActionTypesFile spec, + genQueriesIndex spec, + genActionsIndex spec + ] + +genQueriesIndex :: AppSpec -> Generator FileDraft +genQueriesIndex spec = return $ mkTmplFdWithData relPath (Just tmplData) + where + relPath = [relfile|server/queries/index.ts|] + tmplData = + object + [ "operations" .= map getQueryData (AS.getQueries spec) + ] + +genActionsIndex :: AppSpec -> Generator FileDraft +genActionsIndex spec = return $ mkTmplFdWithData relPath (Just tmplData) + where + relPath = [relfile|server/actions/index.ts|] + tmplData = + object + [ "operations" .= map getActionData (AS.getActions spec) + ] + +genQueryTypesFile :: AppSpec -> Generator FileDraft +genQueryTypesFile spec = genOperationTypesFile tmplFile dstFile operations isAuthEnabledGlobally + where + tmplFile = [relfile|server/queries/types.ts|] + dstFile = [relfile|server/queries/types.ts|] + operations = map (uncurry AS.Operation.QueryOp) $ AS.getQueries spec + isAuthEnabledGlobally = isAuthEnabled spec + +genActionTypesFile :: AppSpec -> Generator FileDraft +genActionTypesFile spec = genOperationTypesFile tmplFile dstFile operations isAuthEnabledGlobally + where + tmplFile = [relfile|server/actions/types.ts|] + dstFile = [relfile|server/actions/types.ts|] + operations = map (uncurry AS.Operation.ActionOp) $ AS.getActions spec + isAuthEnabledGlobally = isAuthEnabled spec + +-- | Here we generate JS file that basically imports JS query function provided by user, +-- decorates it (mostly injects stuff into it) and exports. Idea is that the rest of the server, +-- and user also, should use this new JS function, and not the old one directly. +getQueryData :: (String, AS.Query.Query) -> Aeson.Value +getQueryData (queryName, query) = getOperationTmplData operation + where + operation = AS.Operation.QueryOp queryName query + +getActionData :: (String, AS.Action.Action) -> Aeson.Value +getActionData (actionName, action) = getOperationTmplData operation + where + operation = AS.Operation.ActionOp actionName action + +genOperationTypesFile :: + Path' (Rel C.SdkTemplatesDir) File' -> + Path' (Rel C.SdkRootDir) File' -> + [AS.Operation.Operation] -> + Bool -> + Generator FileDraft +genOperationTypesFile tmplFile dstFile operations isAuthEnabledGlobally = + return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + tmplData = + object + [ "operations" .= map operationTypeData operations, + "shouldImportAuthenticatedOperation" .= any usesAuth operations, + "shouldImportNonAuthenticatedOperation" .= not (all usesAuth operations), + "allEntities" .= nub (concatMap getEntities operations) + ] + operationTypeData operation = + object + [ "typeName" .= toUpperFirst (getName operation), + "entities" .= getEntities operation, + "usesAuth" .= usesAuth operation + ] + getEntities = map makeJsonWithEntityData . maybe [] (map AS.refName) . AS.Operation.getEntities + usesAuth = fromMaybe isAuthEnabledGlobally . AS.Operation.getAuth + +operationsDirInSdkRootDir :: AS.Operation.Operation -> Path' (Rel C.SdkRootDir) Dir' +operationsDirInSdkRootDir (AS.Operation.QueryOp _ _) = [reldir|server/queries|] +operationsDirInSdkRootDir (AS.Operation.ActionOp _ _) = [reldir|server/actions|] + +getOperationTmplData :: AS.Operation.Operation -> Aeson.Value +getOperationTmplData operation = + object + [ "jsFn" .= extOperationImportToImportJson (AS.Operation.getFn operation), + "operationName" .= getName operation, + "operationTypeName" .= toUpperFirst (getName operation), + "entities" + .= maybe [] (map (makeJsonWithEntityData . AS.refName)) (AS.Operation.getEntities operation) + ] + +extOperationImportToImportJson :: EI.ExtImport -> Aeson.Value +extOperationImportToImportJson = + GJI.jsImportToImportJson + . Just + . applyExtImportAlias + . extImportToJsImport + +applyExtImportAlias :: JsImport -> JsImport +applyExtImportAlias jsImport = + jsImport {_importAlias = Just $ JI.getImportIdentifier jsImport ++ "_ext"} + +extImportToJsImport :: EI.ExtImport -> JsImport +extImportToJsImport extImport@(EI.ExtImport extImportName extImportPath) = + JsImport + { _path = ModuleImportPath importPath, + _name = importName, + _importAlias = Just $ EI.importIdentifier extImport ++ "_ext" + } + where + importPath = C.makeSdkImportPath $ extCodeDirP SP.castRel extImportPath + extCodeDirP = fromJust $ SP.relDirToPosix C.extCodeDirInSdkRootDir + importName = GJI.extImportNameToJsImportName extImportName + +-- extImportToImportJson :: EI.ExtImport -> Aeson.Value +-- extImportToImportJson extImport@(EI.ExtImport importName importPath) = +-- object +-- [ "isDefined" .= True, +-- "importStatement" .= Debug.trace jsImportStmt jsImportStmt, +-- "importIdentifier" .= importAlias +-- ] +-- where +-- jsImportStmt = case importName of +-- EI.ExtImportModule n -> "import " ++ n ++ " from '" ++ importPathStr ++ "'" +-- EI.ExtImportField n -> "import { " ++ n ++ " as " ++ importAlias ++ " } from '" ++ importPathStr ++ "'" +-- importPathStr = C.makeSdkImportPath $ extCodeDirP SP.castRel importPath +-- extCodeDirP = fromJust $ SP.relDirToPosix C.extCodeDirInSdkRootDir +-- importAlias = EI.importIdentifier extImport ++ "User" diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index 9a3c905e2b..27173f67a3 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -15,9 +15,7 @@ import Data.Aeson (object, (.=)) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Lazy.UTF8 as ByteStringLazyUTF8 import Data.Maybe - ( fromJust, - fromMaybe, - isJust, + ( isJust, maybeToList, ) import StrongPath @@ -27,7 +25,6 @@ import StrongPath Path', Posix, Rel, - reldir, reldirP, relfile, (), @@ -44,11 +41,7 @@ import Wasp.AppSpec.Valid (getApp, getLowestNodeVersionUserAllows, isAuthEnabled import Wasp.Env (envVarsToDotEnvContent) import Wasp.Generator.Common ( ServerRootDir, - makeJsonWithEntityData, - prismaVersion, ) -import qualified Wasp.Generator.DbGenerator.Auth as DbAuth -import Wasp.Generator.ExternalCodeGenerator (genExternalCodeDir) import Wasp.Generator.FileDraft (FileDraft, createTextFileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.NpmDependencies as N @@ -56,11 +49,9 @@ import Wasp.Generator.ServerGenerator.ApiRoutesG (genApis) import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (depsRequiredByPassport) import Wasp.Generator.ServerGenerator.AuthG (genAuth) import qualified Wasp.Generator.ServerGenerator.Common as C -import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile) import Wasp.Generator.ServerGenerator.CrudG (genCrud) import Wasp.Generator.ServerGenerator.Db.Seed (genDbSeed, getPackageJsonPrismaSeedField) import Wasp.Generator.ServerGenerator.EmailSenderG (depsRequiredByEmail, genEmailSender) -import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeGeneratorStrategy, extSharedCodeGeneratorStrategy) import Wasp.Generator.ServerGenerator.JobGenerator (depsRequiredByJobs, genJobExecutors, genJobs) import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson, getAliasedJsImportStmtAndIdentifier) import Wasp.Generator.ServerGenerator.OperationsG (genOperations) @@ -69,7 +60,7 @@ import Wasp.Generator.ServerGenerator.WebSocketG (depsRequiredByWebSockets, genW import qualified Wasp.Node.Version as NodeVersion import Wasp.Project.Db (databaseUrlEnvVarName) import qualified Wasp.SemanticVersion as SV -import Wasp.Util (toLowerFirst, (<++>)) +import Wasp.Util ((<++>)) genServer :: AppSpec -> Generator [FileDraft] genServer spec = @@ -82,13 +73,10 @@ genServer spec = genGitignore ] <++> genSrcDir spec - <++> genExternalCodeDir extServerCodeGeneratorStrategy (AS.externalServerFiles spec) - <++> genExternalCodeDir extSharedCodeGeneratorStrategy (AS.externalSharedFiles spec) <++> genDotEnv spec <++> genJobs spec <++> genJobExecutors spec <++> genPatches spec - <++> genUniversalDir <++> genEnvValidationScript <++> genExportedTypesDir spec <++> genApis spec @@ -166,7 +154,6 @@ npmDepsForWasp spec = ("cors", "^2.8.5"), ("express", "~4.18.1"), ("morgan", "~1.10.0"), - ("@prisma/client", show prismaVersion), ("jsonwebtoken", "^8.5.1"), -- NOTE: secure-password has a package.json override for sodium-native. ("secure-password", "^4.0.0"), @@ -174,7 +161,6 @@ npmDepsForWasp spec = ("helmet", "^6.0.0"), ("patch-package", "^6.4.7"), ("uuid", "^9.0.0"), - ("lodash.merge", "^4.6.2"), ("rate-limiter-flexible", "^2.4.1"), ("superjson", "^1.12.2") ] @@ -186,7 +172,6 @@ npmDepsForWasp spec = AS.Dependency.fromList [ ("nodemon", "^2.0.19"), ("standard", "^17.0.0"), - ("prisma", show prismaVersion), -- TODO: Allow users to choose whether they want to use TypeScript -- in their projects and install these dependencies accordingly. ("typescript", "^5.1.0"), @@ -221,15 +206,10 @@ genSrcDir :: AppSpec -> Generator [FileDraft] genSrcDir spec = sequence [ genFileCopy [relfile|app.js|], - genFileCopy [relfile|core/AuthError.js|], - genFileCopy [relfile|core/HttpError.js|], - genDbClient spec, - genConfigFile spec, - genServerJs spec + genServerJs spec, + genFileCopy [relfile|polyfill.ts|] ] - <++> genServerUtils spec <++> genRoutesDir spec - <++> genTypesAndEntitiesDirs spec <++> genOperationsRoutes spec <++> genOperations spec <++> genAuth spec @@ -240,24 +220,6 @@ genSrcDir spec = where genFileCopy = return . C.mkSrcTmplFd -genDbClient :: AppSpec -> Generator FileDraft -genDbClient spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) - where - maybeAuth = AS.App.auth $ snd $ getApp spec - - dbClientRelToSrcP = [relfile|dbClient.ts|] - tmplFile = C.asTmplFile $ [reldir|src|] dbClientRelToSrcP - dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile dbClientRelToSrcP - - tmplData = - if isJust maybeAuth - then - object - [ "isAuthEnabled" .= True, - "userEntityUpper" .= (AS.refName (AS.App.Auth.userEntity $ fromJust maybeAuth) :: String) - ] - else object [] - genServerJs :: AppSpec -> Generator FileDraft genServerJs spec = return $ @@ -300,55 +262,6 @@ genRoutesIndex spec = "areThereAnyCrudRoutes" .= (not . null $ AS.getCruds spec) ] -genTypesAndEntitiesDirs :: AppSpec -> Generator [FileDraft] -genTypesAndEntitiesDirs spec = - return - [ entitiesIndexFileDraft, - taggedEntitiesFileDraft, - serializationFileDraft, - typesIndexFileDraft - ] - where - entitiesIndexFileDraft = - C.mkTmplFdWithDstAndData - [relfile|src/entities/index.ts|] - [relfile|src/entities/index.ts|] - ( Just $ - object - [ "entities" .= allEntities, - "isAuthEnabled" .= isJust maybeUserEntityName, - "authEntityName" .= DbAuth.authEntityName, - "authIdentityEntityName" .= DbAuth.authIdentityEntityName - ] - ) - taggedEntitiesFileDraft = - C.mkTmplFdWithDstAndData - [relfile|src/_types/taggedEntities.ts|] - [relfile|src/_types/taggedEntities.ts|] - (Just $ object ["entities" .= allEntities]) - serializationFileDraft = - C.mkSrcTmplFd - [relfile|_types/serialization.ts|] - typesIndexFileDraft = - C.mkTmplFdWithDstAndData - [relfile|src/_types/index.ts|] - [relfile|src/_types/index.ts|] - ( Just $ - object - [ "entities" .= allEntities, - "isAuthEnabled" .= isJust maybeUserEntityName, - "userEntityName" .= userEntityName, - "authEntityName" .= DbAuth.authEntityName, - "authFieldOnUserEntityName" .= DbAuth.authFieldOnUserEntityName, - "authIdentityEntityName" .= DbAuth.authIdentityEntityName, - "identitiesFieldOnAuthEntityName" .= DbAuth.identitiesFieldOnAuthEntityName, - "userFieldName" .= toLowerFirst userEntityName - ] - ) - userEntityName = fromMaybe "" maybeUserEntityName - allEntities = map (makeJsonWithEntityData . fst) $ AS.getDecls @AS.Entity.Entity spec - maybeUserEntityName = AS.refName . AS.App.Auth.userEntity <$> AS.App.auth (snd $ getApp spec) - operationsRouteInRootRouter :: String operationsRouteInRootRouter = "operations" @@ -396,18 +309,10 @@ getPackageJsonOverrides = map buildOverrideData (designateLastElement overrides) map (\(x1, x2, x3) -> (x1, x2, x3, False)) (init l) ++ map (\(x1, x2, x3) -> (x1, x2, x3, True)) [last l] -genUniversalDir :: Generator [FileDraft] -genUniversalDir = - return - [ C.mkUniversalTmplFdWithDst [relfile|url.ts|] [relfile|src/universal/url.ts|], - C.mkUniversalTmplFdWithDst [relfile|types.ts|] [relfile|src/universal/types.ts|] - ] - genEnvValidationScript :: Generator [FileDraft] genEnvValidationScript = return - [ C.mkTmplFd [relfile|scripts/validate-env.mjs|], - C.mkUniversalTmplFdWithDst [relfile|validators.js|] [relfile|scripts/universal/validators.mjs|] + [ C.mkTmplFd [relfile|scripts/validate-env.mjs|] ] genExportedTypesDir :: AppSpec -> Generator [FileDraft] @@ -458,8 +363,3 @@ genOperationsMiddleware spec = (Just tmplData) where tmplData = object ["isAuthEnabled" .= (isAuthEnabled spec :: Bool)] - -genServerUtils :: AppSpec -> Generator [FileDraft] -genServerUtils spec = return [C.mkTmplFdWithData [relfile|src/utils.ts|] (Just tmplData)] - where - tmplData = object ["isAuthEnabled" .= (isAuthEnabled spec :: Bool)] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs index e37f9b4efa..26226783fe 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs @@ -61,7 +61,8 @@ genEmailAuthConfig spec emailAuthConfig = return $ C.mkTmplFdWithDstAndData tmpl "passwordResetClientRoute" .= passwordResetClientRoute, "getPasswordResetEmailContent" .= getPasswordResetEmailContent, "getVerificationEmailContent" .= getVerificationEmailContent, - "allowUnverifiedLogin" .= fromMaybe False (AS.Auth.allowUnverifiedLogin emailAuthConfig) + "userSignupFields" .= extImportToImportJson relPathToServerSrcDir maybeUserSignupFields, + "isDevelopment" .= isDevelopment ] fromFieldJson = @@ -74,10 +75,13 @@ genEmailAuthConfig spec emailAuthConfig = return $ C.mkTmplFdWithDstAndData tmpl maybeName = AS.EmailSender.name fromField email = AS.EmailSender.email fromField + isDevelopment = not $ AS.isBuild spec + emailVerificationClientRoute = getRoutePathFromRef spec $ AS.Auth.EmailVerification.clientRoute emailVerification passwordResetClientRoute = getRoutePathFromRef spec $ AS.Auth.PasswordReset.clientRoute passwordReset getPasswordResetEmailContent = extImportToImportJson relPathToServerSrcDir $ AS.Auth.PasswordReset.getEmailContentFn passwordReset getVerificationEmailContent = extImportToImportJson relPathToServerSrcDir $ AS.Auth.EmailVerification.getEmailContentFn emailVerification + maybeUserSignupFields = AS.Auth.userSignupFieldsForEmailAuth emailAuthConfig emailVerification = AS.Auth.emailVerification emailAuthConfig passwordReset = AS.Auth.passwordReset emailAuthConfig diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs index 9454616204..4209e81cfa 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs @@ -5,10 +5,14 @@ where import Data.Aeson (object, (.=)) import StrongPath - ( File', + ( Dir, + File', + Path, Path', + Posix, Rel, reldir, + reldirP, relfile, (), ) @@ -20,20 +24,23 @@ import qualified Wasp.Generator.AuthProviders.Local as Local import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) import qualified Wasp.Util as Util genLocalAuth :: AS.Auth.Auth -> Generator [FileDraft] -genLocalAuth auth - | AS.Auth.isUsernameAndPasswordAuthEnabled auth = - sequence - [ genLoginRoute auth, - genSignupRoute auth, - genLocalAuthConfig - ] - | otherwise = return [] +genLocalAuth auth = case usernameAndPasswordAuth of + Just usernameAndPasswordAuthConfig -> + sequence + [ genLocalAuthConfig usernameAndPasswordAuthConfig, + genLoginRoute auth, + genSignupRoute auth + ] + Nothing -> return [] + where + usernameAndPasswordAuth = AS.Auth.usernameAndPassword $ AS.Auth.methods auth -genLocalAuthConfig :: Generator FileDraft -genLocalAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) +genLocalAuthConfig :: AS.Auth.UsernameAndPasswordConfig -> Generator FileDraft +genLocalAuthConfig usernameAndPasswordConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where tmplFile = C.srcDirInServerTemplatesDir SP.castRel authIndexFileInSrcDir dstFile = C.serverSrcDirInServerRootDir authIndexFileInSrcDir @@ -41,12 +48,18 @@ genLocalAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tm tmplData = object [ "providerId" .= Local.providerId localAuthProvider, - "displayName" .= Local.displayName localAuthProvider + "displayName" .= Local.displayName localAuthProvider, + "userSignupFields" .= extImportToImportJson relPathToServerSrcDir maybeUserSignupFields ] + maybeUserSignupFields = AS.Auth.userSignupFieldsForUsernameAuth usernameAndPasswordConfig + authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' authIndexFileInSrcDir = [relfile|auth/providers/config/username.ts|] + relPathToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) + relPathToServerSrcDir = [reldirP|../../../|] + genLoginRoute :: AS.Auth.Auth -> Generator FileDraft genLoginRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs index ec052152e1..6004c9c70a 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs @@ -111,10 +111,10 @@ genOAuthConfig provider maybeUserConfig pathToConfigDst = return $ C.mkTmplFdWit "npmPackage" .= App.Dependency.name (OAuth.passportDependency provider), "oAuthConfigProps" .= getJsonForOAuthConfigProps provider, "configFn" .= extImportToImportJson relPathFromAuthConfigToServerSrcDir maybeConfigFn, - "userFieldsFn" .= extImportToImportJson relPathFromAuthConfigToServerSrcDir maybeGetUserFieldsFn + "userSignupFields" .= extImportToImportJson relPathFromAuthConfigToServerSrcDir maybeUserSignupFields ] maybeConfigFn = AS.Auth.configFn =<< maybeUserConfig - maybeGetUserFieldsFn = AS.Auth.getUserFieldsFn =<< maybeUserConfig + maybeUserSignupFields = AS.Auth.userSignupFieldsForExternalAuth =<< maybeUserConfig relPathFromAuthConfigToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) relPathFromAuthConfigToServerSrcDir = [reldirP|../../../|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index 9c2fc4abd0..2baed87ff6 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -1,5 +1,6 @@ module Wasp.Generator.ServerGenerator.AuthG ( genAuth, + depsRequiredByAuth, ) where @@ -9,8 +10,6 @@ import StrongPath ( File', Path', Rel, - reldir, - reldirP, relfile, (), ) @@ -19,6 +18,7 @@ import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.Auth +import qualified Wasp.AppSpec.App.Dependency as AS.Dependency import Wasp.AppSpec.Valid (getApp) import Wasp.Generator.AuthProviders (emailAuthProvider, gitHubAuthProvider, googleAuthProvider, localAuthProvider) import qualified Wasp.Generator.AuthProviders.Email as EmailProvider @@ -31,7 +31,6 @@ import Wasp.Generator.ServerGenerator.Auth.EmailAuthG (genEmailAuth) import Wasp.Generator.ServerGenerator.Auth.LocalAuthG (genLocalAuth) import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (genOAuthAuth) import qualified Wasp.Generator.ServerGenerator.Common as C -import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) import Wasp.Util ((<++>)) import qualified Wasp.Util as Util @@ -39,14 +38,18 @@ genAuth :: AppSpec -> Generator [FileDraft] genAuth spec = case maybeAuth of Just auth -> sequence - [ genCoreAuth auth, - genAuthRoutesIndex auth, - genMeRoute auth, + [ genAuthRoutesIndex auth, + genFileCopy [relfile|routes/auth/me.js|], + genFileCopy [relfile|routes/auth/logout.ts|], genUtils auth, genProvidersIndex auth, genProvidersTypes auth, genFileCopy [relfile|auth/validation.ts|], - genFileCopy [relfile|auth/user.ts|] + genFileCopy [relfile|auth/user.ts|], + genFileCopy [relfile|auth/password.ts|], + genFileCopy [relfile|auth/jwt.ts|], + genSessionTs auth, + genLuciaTs auth ] <++> genIndexTs auth <++> genLocalAuth auth @@ -57,23 +60,6 @@ genAuth spec = case maybeAuth of maybeAuth = AS.App.auth $ snd $ getApp spec genFileCopy = return . C.mkSrcTmplFd --- | Generates core/auth file which contains auth middleware and createUser() function. -genCoreAuth :: AS.Auth.Auth -> Generator FileDraft -genCoreAuth auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) - where - coreAuthRelToSrc = [relfile|core/auth.js|] - tmplFile = C.asTmplFile $ [reldir|src|] coreAuthRelToSrc - dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile coreAuthRelToSrc - - tmplData = - let userEntityName = AS.refName $ AS.Auth.userEntity auth - in object - [ "userEntityUpper" .= (userEntityName :: String), - "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), - "authFieldOnUserEntityName" .= (DbAuth.authFieldOnUserEntityName :: String), - "identitiesFieldOnAuthEntityName" .= (DbAuth.identitiesFieldOnAuthEntityName :: String) - ] - genAuthRoutesIndex :: AS.Auth.Auth -> Generator FileDraft genAuthRoutesIndex auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where @@ -85,15 +71,6 @@ genAuthRoutesIndex auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Ju authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' authIndexFileInSrcDir = [relfile|routes/auth/index.js|] -genMeRoute :: AS.Auth.Auth -> Generator FileDraft -genMeRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) - where - meRouteRelToSrc = [relfile|routes/auth/me.js|] - tmplFile = C.asTmplFile $ [reldir|src|] meRouteRelToSrc - dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile meRouteRelToSrc - - tmplData = object ["userEntityLower" .= (Util.toLowerFirst (AS.refName $ AS.Auth.userEntity auth) :: String)] - genUtils :: AS.Auth.Auth -> Generator FileDraft genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where @@ -112,15 +89,12 @@ genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplDat "authFieldOnUserEntityName" .= (DbAuth.authFieldOnUserEntityName :: String), "identitiesFieldOnAuthEntityName" .= (DbAuth.identitiesFieldOnAuthEntityName :: String), "failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo auth, - "successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth, - "additionalSignupFields" .= extImportToImportJson [reldirP|../|] additionalSignupFields + "successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth ] utilsFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' utilsFileInSrcDir = [relfile|auth/utils.ts|] - additionalSignupFields = AS.Auth.signup auth >>= AS.Auth.additionalFields - genIndexTs :: AS.Auth.Auth -> Generator [FileDraft] genIndexTs auth = return $ @@ -158,3 +132,38 @@ genProvidersTypes auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers userEntityName = AS.refName $ AS.Auth.userEntity auth tmplData = object ["userEntityUpper" .= (userEntityName :: String)] + +genLuciaTs :: AS.Auth.Auth -> Generator FileDraft +genLuciaTs auth = return $ C.mkTmplFdWithData [relfile|src/auth/lucia.ts|] (Just tmplData) + where + tmplData = + object + [ "sessionEntityLower" .= (Util.toLowerFirst DbAuth.sessionEntityName :: String), + "authEntityLower" .= (Util.toLowerFirst DbAuth.authEntityName :: String), + "userEntityUpper" .= (userEntityName :: String) + ] + + userEntityName = AS.refName $ AS.Auth.userEntity auth + +genSessionTs :: AS.Auth.Auth -> Generator FileDraft +genSessionTs auth = return $ C.mkTmplFdWithData [relfile|src/auth/session.ts|] (Just tmplData) + where + tmplData = + object + [ "userEntityUpper" .= userEntityName, + "userEntityLower" .= Util.toLowerFirst userEntityName, + "authFieldOnUserEntityName" .= DbAuth.authFieldOnUserEntityName, + "identitiesFieldOnAuthEntityName" .= DbAuth.identitiesFieldOnAuthEntityName + ] + + userEntityName = AS.refName $ AS.Auth.userEntity auth + +depsRequiredByAuth :: AppSpec -> [AS.Dependency.Dependency] +depsRequiredByAuth spec = maybe [] (const authDeps) maybeAuth + where + maybeAuth = AS.App.auth $ snd $ getApp spec + authDeps = + AS.Dependency.fromList + [ ("lucia", "^3.0.0-beta.14"), + ("@lucia-auth/adapter-prisma", "^4.0.0-beta.9") + ] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs b/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs deleted file mode 100644 index 7d55fd8d07..0000000000 --- a/waspc/src/Wasp/Generator/ServerGenerator/ConfigG.hs +++ /dev/null @@ -1,30 +0,0 @@ -module Wasp.Generator.ServerGenerator.ConfigG - ( genConfigFile, - ) -where - -import Data.Aeson (object, (.=)) -import StrongPath (File', Path', Rel, relfile, ()) -import qualified StrongPath as SP -import Wasp.AppSpec (AppSpec) -import Wasp.AppSpec.Valid (isAuthEnabled) -import Wasp.Generator.FileDraft (FileDraft) -import Wasp.Generator.Monad (Generator) -import qualified Wasp.Generator.ServerGenerator.Common as C -import qualified Wasp.Generator.WebAppGenerator.Common as WebApp -import Wasp.Project.Db (databaseUrlEnvVarName) - -genConfigFile :: AppSpec -> Generator FileDraft -genConfigFile spec = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) - where - tmplFile = C.srcDirInServerTemplatesDir SP.castRel configFileInSrcDir - dstFile = C.serverSrcDirInServerRootDir configFileInSrcDir - tmplData = - object - [ "isAuthEnabled" .= isAuthEnabled spec, - "databaseUrlEnvVarName" .= databaseUrlEnvVarName, - "defaultClientUrl" .= WebApp.getDefaultClientUrl spec - ] - -configFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' -configFileInSrcDir = [relfile|config.js|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/CrudG.hs b/waspc/src/Wasp/Generator/ServerGenerator/CrudG.hs index 242ce4596f..0b5689d582 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/CrudG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/CrudG.hs @@ -26,6 +26,7 @@ import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator.Common as C import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) +import Wasp.JsImport (JsImportPath (RelativeImportPath)) import qualified Wasp.JsImport as JI import Wasp.Util ((<++>)) @@ -59,7 +60,7 @@ genCrudIndexRoute cruds = return $ C.mkTmplFdWithData tmplPath (Just tmplData) JI.getJsImportStmtAndIdentifier JI.JsImport { JI._name = JI.JsImportField name, - JI._path = fromJust . SP.relFileToPosix $ getCrudFilePath name "js", + JI._path = RelativeImportPath (fromJust . SP.relFileToPosix $ getCrudFilePath name "js"), JI._importAlias = Nothing } diff --git a/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs b/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs index afcff7682a..f12c26ed36 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/EmailSender/Providers.hs @@ -2,6 +2,7 @@ module Wasp.Generator.ServerGenerator.EmailSender.Providers ( smtp, sendGrid, mailgun, + dummy, providersDirInServerSrc, EmailSenderProvider (..), ) @@ -13,7 +14,7 @@ import qualified Wasp.Generator.ServerGenerator.Common as C import qualified Wasp.SemanticVersion as SV data EmailSenderProvider = EmailSenderProvider - { npmDependency :: AS.Dependency.Dependency, + { npmDependency :: Maybe AS.Dependency.Dependency, setupFnFile :: Path' (Rel ProvidersDir) File', -- We have to use explicit boolean keys in templates (e.g. "isSMTPProviderEnabled") so each -- provider provides its own key which we pass to the template. @@ -26,7 +27,7 @@ data ProvidersDir smtp :: EmailSenderProvider smtp = EmailSenderProvider - { npmDependency = nodeMailerDependency, + { npmDependency = Just nodeMailerDependency, setupFnFile = [relfile|smtp.ts|], isEnabledKey = "isSmtpProviderUsed" } @@ -40,7 +41,7 @@ smtp = sendGrid :: EmailSenderProvider sendGrid = EmailSenderProvider - { npmDependency = sendGridDependency, + { npmDependency = Just sendGridDependency, setupFnFile = [relfile|sendgrid.ts|], isEnabledKey = "isSendGridProviderUsed" } @@ -54,7 +55,7 @@ sendGrid = mailgun :: EmailSenderProvider mailgun = EmailSenderProvider - { npmDependency = mailgunDependency, + { npmDependency = Just mailgunDependency, setupFnFile = [relfile|mailgun.ts|], isEnabledKey = "isMailgunProviderUsed" } @@ -65,5 +66,13 @@ mailgun = mailgunDependency :: AS.Dependency.Dependency mailgunDependency = AS.Dependency.make ("ts-mailgun", show mailgunVersionRange) +dummy :: EmailSenderProvider +dummy = + EmailSenderProvider + { npmDependency = Nothing, + setupFnFile = [relfile|dummy.ts|], + isEnabledKey = "isDummyProviderUsed" + } + providersDirInServerSrc :: Path' (Rel C.ServerTemplatesSrcDir) (Dir ProvidersDir) providersDirInServerSrc = [reldir|email/core/providers|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs b/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs index 63c59df100..0ba63ee8bc 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/EmailSenderG.hs @@ -39,8 +39,7 @@ genCore email = sequence [ genCoreIndex email, genCoreTypes email, - genCoreHelpers email, - genFileCopy [relfile|email/core/providers/dummy.ts|] + genCoreHelpers email ] <++> genEmailSenderProviderSetupFn email @@ -94,7 +93,7 @@ depsRequiredByEmail spec = maybeToList maybeNpmDepedency where maybeProvider :: Maybe Providers.EmailSenderProvider maybeProvider = getEmailSenderProvider <$> (AS.App.emailSender . snd . getApp $ spec) - maybeNpmDepedency = Providers.npmDependency <$> maybeProvider + maybeNpmDepedency = maybeProvider >>= Providers.npmDependency getEmailProvidersJson :: EmailSender -> Aeson.Value getEmailProvidersJson email = @@ -109,6 +108,7 @@ getEmailSenderProvider email = case AS.EmailSender.provider email of AS.EmailSender.SMTP -> Providers.smtp AS.EmailSender.SendGrid -> Providers.sendGrid AS.EmailSender.Mailgun -> Providers.mailgun + AS.EmailSender.Dummy -> Providers.dummy genFileCopy :: Path' (Rel C.ServerTemplatesSrcDir) File' -> Generator FileDraft genFileCopy = return . C.mkSrcTmplFd diff --git a/waspc/src/Wasp/Generator/ServerGenerator/ExternalCodeGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator/ExternalCodeGenerator.hs deleted file mode 100644 index 7d69a5b2a2..0000000000 --- a/waspc/src/Wasp/Generator/ServerGenerator/ExternalCodeGenerator.hs +++ /dev/null @@ -1,43 +0,0 @@ -module Wasp.Generator.ServerGenerator.ExternalCodeGenerator - ( extServerCodeGeneratorStrategy, - extServerCodeDirInServerSrcDir, - extSharedCodeGeneratorStrategy, - ) -where - -import StrongPath (Dir, Path', Rel, reldir, ()) -import qualified StrongPath as SP -import Wasp.Generator.ExternalCodeGenerator.Common - ( ExternalCodeGeneratorStrategy (..), - GeneratedExternalCodeDir, - castRelPathFromSrcToGenExtCodeDir, - ) -import Wasp.Generator.ExternalCodeGenerator.Js (resolveJsFileWaspImportsForExtCodeDir) -import qualified Wasp.Generator.ServerGenerator.Common as C - -extServerCodeGeneratorStrategy :: ExternalCodeGeneratorStrategy -extServerCodeGeneratorStrategy = mkExtCodeGeneratorStrategy extServerCodeDirInServerSrcDir - -extSharedCodeGeneratorStrategy :: ExternalCodeGeneratorStrategy -extSharedCodeGeneratorStrategy = mkExtCodeGeneratorStrategy extSharedCodeDirInServerSrcDir - --- | Relative path to the directory where external server code will be generated. --- Relative to the server src dir. -extServerCodeDirInServerSrcDir :: Path' (Rel C.ServerSrcDir) (Dir GeneratedExternalCodeDir) -extServerCodeDirInServerSrcDir = [reldir|ext-src|] - --- | Relative path to the directory where external shared code will be generated. --- Relative to the server src dir. -extSharedCodeDirInServerSrcDir :: Path' (Rel C.ServerSrcDir) (Dir GeneratedExternalCodeDir) -extSharedCodeDirInServerSrcDir = [reldir|shared|] - -mkExtCodeGeneratorStrategy :: Path' (Rel C.ServerSrcDir) (Dir GeneratedExternalCodeDir) -> ExternalCodeGeneratorStrategy -mkExtCodeGeneratorStrategy extCodeDirInServerSrcDir = - ExternalCodeGeneratorStrategy - { _resolveJsFileWaspImports = resolveJsFileWaspImportsForExtCodeDir (SP.castRel extCodeDirInServerSrcDir), - _resolveDstFilePath = \filePath -> - C.serverRootDirInProjectRootDir - C.serverSrcDirInServerRootDir - extCodeDirInServerSrcDir - castRelPathFromSrcToGenExtCodeDir filePath - } diff --git a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs index 448d9e05df..292264f898 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs @@ -1,13 +1,11 @@ module Wasp.Generator.ServerGenerator.JsImport where import qualified Data.Aeson as Aeson -import Data.Maybe (fromJust) import StrongPath (Dir, Path, Posix, Rel) -import qualified StrongPath as SP +import StrongPath.TH (reldirP) import qualified Wasp.AppSpec.ExtImport as EI import qualified Wasp.Generator.JsImport as GJI import Wasp.Generator.ServerGenerator.Common (ServerSrcDir) -import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeDirInServerSrcDir) import Wasp.JsImport ( JsImport, JsImportAlias, @@ -44,4 +42,7 @@ extImportToJsImport :: JsImport extImportToJsImport = GJI.extImportToJsImport serverExtDir where - serverExtDir = fromJust (SP.relDirToPosix extServerCodeDirInServerSrcDir) + -- filip: Instead of generating the ext-src folder with the user's code and referencing that, we reference user code directly. + -- This gives us proper error messages (with user's file names and line numbers). + -- It works great with Vite (Vite outputs absolute file paths), but less great on the server (TS outputs relative paths, resulting in ../../src/something) + serverExtDir = [reldirP|../../../../src|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs b/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs index 4da990b3a1..cab064810d 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/OperationsRoutesG.hs @@ -8,7 +8,7 @@ where import Data.Aeson (object, (.=)) import Data.Maybe (fromJust, fromMaybe, isJust) -import StrongPath (Dir, File', Path, Path', Posix, Rel, reldir, reldirP, relfile, ()) +import StrongPath (Dir, File', Path', Rel, reldir, relfile, ()) import qualified StrongPath as SP import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS @@ -19,9 +19,15 @@ import Wasp.AppSpec.Valid (isAuthEnabled) import Wasp.Generator.Common (ServerRootDir) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator, GeneratorError (GenericGeneratorError), logAndThrowGeneratorError) +import Wasp.Generator.SdkGenerator.Common (makeSdkImportPath, relDirToRelFileP) +import Wasp.Generator.SdkGenerator.ServerOpsGenerator (operationsDirInSdkRootDir) import qualified Wasp.Generator.ServerGenerator.Common as C -import Wasp.Generator.ServerGenerator.OperationsG (operationFileInSrcDir) -import Wasp.JsImport (JsImportName (..), getJsImportStmtAndIdentifier, makeJsImport) +import Wasp.JsImport + ( JsImportName (..), + JsImportPath (ModuleImportPath, RelativeImportPath), + getJsImportStmtAndIdentifier, + makeJsImport, + ) import qualified Wasp.Util as U genOperationsRoutes :: AppSpec -> Generator [FileDraft] @@ -58,21 +64,16 @@ genOperationRoute operation tmplFile = return $ C.mkTmplFdWithDstAndData tmplFil ] ] - pathToOperationFile = - relPosixPathFromOperationsRoutesDirToSrcDir - fromJust (SP.relFileToPosix $ operationFileInSrcDir operation) - - operationImportPath = - fromJust $ - SP.parseRelFileP $ - C.toESModulesImportPath $ - SP.fromRelFileP pathToOperationFile - operationName = AS.Operation.getName operation (operationImportStmt, operationImportIdentifier) = getJsImportStmtAndIdentifier $ - makeJsImport operationImportPath (JsImportModule operationName) + makeJsImport (ModuleImportPath sdkImportPath) (JsImportField operationName) + sdkImportPath = + makeSdkImportPath $ + relDirToRelFileP $ + fromJust $ + SP.relDirToPosix $ operationsDirInSdkRootDir operation data OperationsRoutesDir @@ -85,9 +86,6 @@ operationsRoutesDirInServerRootDir = C.serverSrcDirInServerRootDir operation operationRouteFileInOperationsRoutesDir :: AS.Operation.Operation -> Path' (Rel OperationsRoutesDir) File' operationRouteFileInOperationsRoutesDir operation = fromJust $ SP.parseRelFile $ AS.Operation.getName operation ++ ".js" -relPosixPathFromOperationsRoutesDirToSrcDir :: Path Posix (Rel OperationsRoutesDir) (Dir C.ServerSrcDir) -relPosixPathFromOperationsRoutesDirToSrcDir = [reldirP|../..|] - genOperationsRouter :: AppSpec -> Generator FileDraft genOperationsRouter spec -- TODO: Right now we are throwing error here, but we should instead perform this check in parsing/analyzer phase, as a semantic check, since we have all the info we need then already. @@ -107,7 +105,7 @@ genOperationsRouter spec makeOperationRoute operation = let operationName = AS.Operation.getName operation importPath = fromJust $ SP.relFileToPosix $ SP.castRel $ operationRouteFileInOperationsRoutesDir operation - (importStmt, importIdentifier) = getJsImportStmtAndIdentifier $ makeJsImport importPath (JsImportModule operationName) + (importStmt, importIdentifier) = getJsImportStmtAndIdentifier $ makeJsImport (RelativeImportPath importPath) (JsImportModule operationName) in object [ "importIdentifier" .= importIdentifier, "importStatement" .= importStmt, diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Setup.hs b/waspc/src/Wasp/Generator/ServerGenerator/Setup.hs deleted file mode 100644 index 03194b729f..0000000000 --- a/waspc/src/Wasp/Generator/ServerGenerator/Setup.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Wasp.Generator.ServerGenerator.Setup - ( installNpmDependencies, - ) -where - -import StrongPath (Abs, Dir, Path', ()) -import Wasp.Generator.Common (ProjectRootDir) -import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.Process (runNodeCommandAsJob) -import qualified Wasp.Generator.ServerGenerator.Common as Common - -installNpmDependencies :: Path' Abs (Dir ProjectRootDir) -> J.Job -installNpmDependencies projectDir = do - let serverDir = projectDir Common.serverRootDirInProjectRootDir - runNodeCommandAsJob serverDir "npm" ["install"] J.Server diff --git a/waspc/src/Wasp/Generator/Setup.hs b/waspc/src/Wasp/Generator/Setup.hs index 05ba4912e2..3c81bd071d 100644 --- a/waspc/src/Wasp/Generator/Setup.hs +++ b/waspc/src/Wasp/Generator/Setup.hs @@ -3,22 +3,27 @@ module Wasp.Generator.Setup ) where -import Control.Concurrent (threadDelay) -import Control.Concurrent.Async (race) import Control.Monad (when) import StrongPath (Abs, Dir, Path') -import Wasp.AppSpec (AppSpec) +import Wasp.AppSpec (AppSpec (waspProjectDir)) import Wasp.Generator.Common (ProjectRootDir) import qualified Wasp.Generator.DbGenerator as DbGenerator import Wasp.Generator.Monad (GeneratorError (..), GeneratorWarning (..)) import Wasp.Generator.NpmInstall (installNpmDependenciesWithInstallRecord, isNpmInstallNeeded) +import qualified Wasp.Generator.SdkGenerator as SdkGenerator import qualified Wasp.Message as Msg -import qualified Wasp.Util.Terminal as Term runSetup :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> Msg.SendMessage -> IO ([GeneratorWarning], [GeneratorError]) -runSetup spec dstDir sendMessage = do - runNpmInstallIfNeeded spec dstDir sendMessage >>= \case - npmInstallResults@(_, []) -> (npmInstallResults <>) <$> setUpDatabase spec dstDir sendMessage +runSetup spec projectRootDir sendMessage = do + runNpmInstallIfNeeded spec projectRootDir sendMessage >>= \case + npmInstallResults@(_, []) -> + setUpDatabase spec projectRootDir sendMessage >>= \case + setUpDatabaseResults@(_, []) -> do + -- todo(filip): Should we consider building SDK as part of code generation? + -- todo(filip): Avoid building on each setup if we don't need to. + buildsSdkResults <- buildSdk projectRootDir sendMessage + return $ npmInstallResults <> setUpDatabaseResults <> buildsSdkResults + setUpDatabaseResults -> return $ npmInstallResults <> setUpDatabaseResults npmInstallResults -> return npmInstallResults runNpmInstallIfNeeded :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> Msg.SendMessage -> IO ([GeneratorWarning], [GeneratorError]) @@ -28,33 +33,10 @@ runNpmInstallIfNeeded spec dstDir sendMessage = do Right maybeFullStackDeps -> case maybeFullStackDeps of Nothing -> return ([], []) Just fullStackDeps -> do - sendMessage $ Msg.Start "Starting npm install..." - (Left (npmInstallWarnings, npmInstallErrors)) <- - installNpmDependenciesWithInstallRecord fullStackDeps dstDir - `race` reportInstallationProgress reportInstallationProgressMessages + (npmInstallWarnings, npmInstallErrors) <- + installNpmDependenciesWithInstallRecord fullStackDeps (waspProjectDir spec) dstDir when (null npmInstallErrors) (sendMessage $ Msg.Success "Successfully completed npm install.") return (npmInstallWarnings, npmInstallErrors) - where - reportInstallationProgress :: [String] -> IO () - reportInstallationProgress messages = do - threadDelay $ secToMicroSec 5 - putStrLn $ Term.applyStyles [Term.Yellow] $ "\n\n ..." ++ head messages - threadDelay $ secToMicroSec 5 - reportInstallationProgress $ if hasLessThan2Elems messages then messages else drop 1 messages - - reportInstallationProgressMessages = - [ "Still installing npm dependencies!", - "Installation going great - we'll get there soon!", - "The installation is taking a while, but we'll get there!", - "Yup, still not done installing.", - "We're getting closer and closer, everything will be installed soon!", - "Still waiting for the installation to finish? You should! We got too far to give up now!", - "You've been waiting so patiently, just wait a little longer (for the installation to finish)..." - ] - - secToMicroSec = (* 1000000) - - hasLessThan2Elems = null . drop 1 setUpDatabase :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> Msg.SendMessage -> IO ([GeneratorWarning], [GeneratorError]) setUpDatabase spec dstDir sendMessage = do @@ -62,3 +44,12 @@ setUpDatabase spec dstDir sendMessage = do (dbGeneratorWarnings, dbGeneratorErrors) <- DbGenerator.postWriteDbGeneratorActions spec dstDir when (null dbGeneratorErrors) (sendMessage $ Msg.Success "Database successfully set up.") return (dbGeneratorWarnings, dbGeneratorErrors) + +buildSdk :: Path' Abs (Dir ProjectRootDir) -> Msg.SendMessage -> IO ([GeneratorWarning], [GeneratorError]) +buildSdk projectRootDir sendMessage = do + sendMessage $ Msg.Start "Building SDK..." + SdkGenerator.buildSdk projectRootDir >>= \case + Left errorMesage -> return ([], [GenericGeneratorError errorMesage]) + Right () -> do + sendMessage $ Msg.Success "SDK built successfully." + return ([], []) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator.hs index 5c6ee9a780..1f88fdf82c 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator.hs @@ -31,17 +31,14 @@ import qualified Wasp.AppSpec.App.Client as AS.App.Client import qualified Wasp.AppSpec.App.Dependency as AS.Dependency import Wasp.AppSpec.App.WebSocket (WebSocket (..)) import qualified Wasp.AppSpec.Entity as AS.Entity -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) -import Wasp.AppSpec.Valid (getApp, isAuthEnabled) +import Wasp.AppSpec.ExternalFiles (SourceExternalCodeDir) +import Wasp.AppSpec.Valid (getApp) import Wasp.Env (envVarsToDotEnvContent) import Wasp.Generator.Common ( makeJsonWithEntityData, - prismaVersion, ) import qualified Wasp.Generator.ConfigFile as G.CF import qualified Wasp.Generator.DbGenerator.Auth as DbAuth -import Wasp.Generator.ExternalCodeGenerator (genExternalCodeDir) -import qualified Wasp.Generator.ExternalCodeGenerator.Common as ECC import Wasp.Generator.FileDraft (FileDraft, createTextFileDraft) import qualified Wasp.Generator.FileDraft as FD import Wasp.Generator.JsImport (jsImportToImportJson) @@ -50,11 +47,6 @@ import qualified Wasp.Generator.NpmDependencies as N import Wasp.Generator.WebAppGenerator.AuthG (genAuth) import qualified Wasp.Generator.WebAppGenerator.Common as C import Wasp.Generator.WebAppGenerator.CrudG (genCrud) -import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator - ( extClientCodeGeneratorStrategy, - extSharedCodeGeneratorStrategy, - ) -import qualified Wasp.Generator.WebAppGenerator.ExternalCodeGenerator as EC import Wasp.Generator.WebAppGenerator.JsImport (extImportToImportJson) import Wasp.Generator.WebAppGenerator.OperationsGenerator (genOperations) import Wasp.Generator.WebAppGenerator.RouterGenerator (genRouter) @@ -62,15 +54,14 @@ import qualified Wasp.Generator.WebSocket as AS.WS import Wasp.JsImport ( JsImport, JsImportName (JsImportModule), + JsImportPath (RelativeImportPath), makeJsImport, ) import qualified Wasp.Node.Version as NodeVersion -import qualified Wasp.SemanticVersion as SV import Wasp.Util ((<++>)) genWebApp :: AppSpec -> Generator [FileDraft] genWebApp spec = do - extClientCodeFileDrafts <- genExternalCodeDir extClientCodeGeneratorStrategy (AS.externalClientFiles spec) sequence [ genFileCopy [relfile|README.md|], genFileCopy [relfile|tsconfig.json|], @@ -86,11 +77,8 @@ genWebApp spec = do genViteConfig spec ] <++> genSrcDir spec - <++> return extClientCodeFileDrafts - <++> genExternalCodeDir extSharedCodeGeneratorStrategy (AS.externalSharedFiles spec) - <++> genPublicDir spec extClientCodeFileDrafts + <++> genPublicDir spec <++> genDotEnv spec - <++> genUniversalDir <++> genEnvValidationScript <++> genCrud spec where @@ -144,28 +132,22 @@ npmDepsForWasp spec = ("react-dom", "^18.2.0"), ("@tanstack/react-query", "^4.29.0"), ("react-router-dom", "^5.3.3"), - -- The web app only needs @prisma/client (we're using the server's - -- CLI to generate what's necessary, check the description in - -- https://github.com/wasp-lang/wasp/pull/962/ for details). - ("@prisma/client", show prismaVersion), ("superjson", "^1.12.2"), ("mitt", "3.0.0"), -- Used for Auth UI ("react-hook-form", "^7.45.4") ] - ++ depsRequiredForAuth spec ++ depsRequiredByTailwind spec ++ depsRequiredForWebSockets spec, N.waspDevDependencies = AS.Dependency.fromList [ -- TODO: Allow users to choose whether they want to use TypeScript -- in their projects and install these dependencies accordingly. - ("vite", "^4.3.9"), ("typescript", "^5.1.0"), ("@types/react", "^18.0.37"), ("@types/react-dom", "^18.0.11"), ("@types/react-router-dom", "^5.3.3"), - ("@vitejs/plugin-react-swc", "^3.0.0"), + ("@vitejs/plugin-react", "^4.2.1"), ("dotenv", "^16.0.3"), -- NOTE: Make sure to bump the version of the tsconfig -- when updating Vite or React versions @@ -174,12 +156,6 @@ npmDepsForWasp spec = ++ depsRequiredForTesting } -depsRequiredForAuth :: AppSpec -> [AS.Dependency.Dependency] -depsRequiredForAuth spec = - [AS.Dependency.make ("@stitches/react", show versionRange) | isAuthEnabled spec] - where - versionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 1 2 8)] - depsRequiredByTailwind :: AppSpec -> [AS.Dependency.Dependency] depsRequiredByTailwind spec = if G.CF.isTailwindUsed spec @@ -214,25 +190,24 @@ genGitignore = (C.asTmplFile [relfile|gitignore|]) (C.asWebAppFile [relfile|.gitignore|]) -genPublicDir :: AppSpec -> [FileDraft] -> Generator [FileDraft] -genPublicDir spec extCodeFileDrafts = +genPublicDir :: AppSpec -> Generator [FileDraft] +genPublicDir spec = return $ - ifUserDidntProvideFile genFaviconFd + extPublicFileDrafts + ++ ifUserDidntProvideFile genFaviconFd ++ ifUserDidntProvideFile genManifestFd where + publicFiles = AS.externalPublicFiles spec + extPublicFileDrafts = map C.mkPublicFileDraft publicFiles genFaviconFd = C.mkTmplFd (C.asTmplFile [relfile|public/favicon.ico|]) genManifestFd = C.mkTmplFdWithData tmplFile tmplData where tmplData = object ["appName" .= (fst (getApp spec) :: String)] tmplFile = C.asTmplFile [relfile|public/manifest.json|] - ifUserDidntProvideFile fileDraft = - if checkIfFileDraftExists fileDraft - then [] - else [fileDraft] - + ifUserDidntProvideFile fileDraft = [fileDraft | not (checkIfFileDraftExists fileDraft)] checkIfFileDraftExists = (`elem` existingDstPaths) . FD.getDstPath - existingDstPaths = map FD.getDstPath extCodeFileDrafts + existingDstPaths = map FD.getDstPath extPublicFileDrafts genIndexHtml :: AppSpec -> Generator FileDraft genIndexHtml spec = @@ -256,15 +231,10 @@ genSrcDir :: AppSpec -> Generator [FileDraft] genSrcDir spec = sequence [ genFileCopy [relfile|logo.png|], - genFileCopy [relfile|config.js|], genFileCopy [relfile|queryClient.js|], genFileCopy [relfile|utils.js|], genFileCopy [relfile|types.ts|], genFileCopy [relfile|vite-env.d.ts|], - -- Generates api.js file which contains token management and configured api (e.g. axios) instance. - genFileCopy [relfile|api.ts|], - genFileCopy [relfile|api/events.ts|], - genFileCopy [relfile|storage.ts|], getIndexTs spec ] <++> genOperations spec @@ -311,27 +281,19 @@ getIndexTs spec = relPathToWebAppSrcDir :: Path Posix (Rel importLocation) (Dir C.WebAppSrcDir) relPathToWebAppSrcDir = [reldirP|./|] -genUniversalDir :: Generator [FileDraft] -genUniversalDir = - return - [ C.mkUniversalTmplFdWithDst [relfile|url.ts|] [relfile|src/universal/url.ts|], - C.mkUniversalTmplFdWithDst [relfile|types.ts|] [relfile|src/universal/types.ts|] - ] - genEnvValidationScript :: Generator [FileDraft] genEnvValidationScript = return - [ C.mkTmplFd [relfile|scripts/validate-env.mjs|], - C.mkUniversalTmplFdWithDst [relfile|validators.js|] [relfile|scripts/universal/validators.mjs|] + [ C.mkTmplFd [relfile|scripts/validate-env.mjs|] ] genWebSockets :: AppSpec -> Generator [FileDraft] genWebSockets spec | AS.WS.areWebSocketsUsed spec = - sequence - [ genFileCopy [relfile|webSocket.ts|], - genWebSocketProvider spec - ] + sequence + [ genFileCopy [relfile|webSocket.ts|], + genWebSocketProvider spec + ] | otherwise = return [] where genFileCopy = return . C.mkSrcTmplFd @@ -344,6 +306,7 @@ genWebSocketProvider spec = return $ C.mkTmplFdWithData tmplFile tmplData tmplData = object ["autoConnect" .= map toLower (show shouldAutoConnect)] tmplFile = C.asTmplFile [relfile|src/webSocket/WebSocketProvider.tsx|] +-- todo(filip): Take care of this as well genViteConfig :: AppSpec -> Generator FileDraft genViteConfig spec = return $ C.mkTmplFdWithData tmplFile tmplData where @@ -356,13 +319,9 @@ genViteConfig spec = return $ C.mkTmplFdWithData tmplFile tmplData ] makeCustomViteConfigJsImport :: Path' (Rel SourceExternalCodeDir) File' -> JsImport - makeCustomViteConfigJsImport pathToConfig = makeJsImport importPath importName + makeCustomViteConfigJsImport pathToConfig = makeJsImport (RelativeImportPath importPath) importName where importPath = C.toViteImportPath $ fromJust $ SP.relFileToPosix pathToConfigInSrc - pathToConfigInSrc = - SP.castRel $ - C.webAppSrcDirInWebAppRootDir - EC.extClientCodeDirInWebAppSrcDir - ECC.castRelPathFromSrcToGenExtCodeDir pathToConfig + pathToConfigInSrc = SP.castRel $ C.webAppSrcDirInWebAppRootDir SP.castRel pathToConfig importName = JsImportModule "customViteConfig" diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs index b82ca2e54b..1ded17dc1a 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Auth/AuthFormsG.hs @@ -20,8 +20,7 @@ genAuthForms auth = [ genAuthComponent auth, genTypes auth, genFileCopy [relfile|auth/forms/Login.tsx|], - genFileCopy [relfile|auth/forms/Signup.tsx|], - genFileCopy [relfile|stitches.config.js|] + genFileCopy [relfile|auth/forms/Signup.tsx|] ] <++> genEmailForms auth <++> genInternalAuthComponents auth @@ -118,8 +117,7 @@ genLoginSignupForm auth = -- Username and password "isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth, -- Email - "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth, - "isEmailVerificationRequired" .= AS.Auth.isEmailVerificationRequired auth + "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth ] areBothSocialAndPasswordBasedAuthEnabled = AS.Auth.isExternalAuthEnabled auth && isAnyPasswordBasedAuthEnabled isAnyPasswordBasedAuthEnabled = AS.Auth.isUsernameAndPasswordAuthEnabled auth || AS.Auth.isEmailAuthEnabled auth diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs index 72004178f2..6a67b77f12 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs @@ -26,10 +26,10 @@ genAuth spec = Nothing -> return [] Just auth -> sequence - [ genFileCopy [relfile|auth/logout.ts|], - genFileCopy [relfile|auth/helpers/user.ts|], + [ genFileCopy [relfile|auth/helpers/user.ts|], genFileCopy [relfile|auth/types.ts|], genFileCopy [relfile|auth/user.ts|], + genFileCopy [relfile|auth/logout.ts|], genUseAuth auth, genCreateAuthRequiredPage auth ] diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs index 06005ca2bb..ef001efdef 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Common.hs @@ -6,6 +6,7 @@ module Wasp.Generator.WebAppGenerator.Common mkTmplFdWithDst, mkTmplFdWithData, mkTmplFdWithDstAndData, + mkPublicFileDraft, webAppSrcDirInProjectRootDir, webAppTemplatesDirInTemplatesDir, asTmplFile, @@ -34,6 +35,8 @@ import System.FilePath (splitExtension) import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Client as AS.App.Client +import Wasp.AppSpec.ExternalFiles (PublicFile (..)) +import qualified Wasp.AppSpec.ExternalFiles as EF import Wasp.AppSpec.Valid (getApp) import Wasp.Generator.Common ( GeneratedSrcDir, @@ -43,7 +46,7 @@ import Wasp.Generator.Common WebAppRootDir, universalTemplatesDirInTemplatesDir, ) -import Wasp.Generator.FileDraft (FileDraft, createTemplateFileDraft) +import Wasp.Generator.FileDraft (FileDraft, createCopyFileDraft, createTemplateFileDraft) import Wasp.Generator.Templates (TemplatesDir) data WebAppSrcDir @@ -108,6 +111,14 @@ mkTmplFdWithDst src dst = mkTmplFdWithDstAndData src dst Nothing mkTmplFdWithData :: Path' (Rel WebAppTemplatesDir) File' -> Aeson.Value -> FileDraft mkTmplFdWithData src tmplData = mkTmplFdWithDstAndData src (SP.castRel src) (Just tmplData) +mkPublicFileDraft :: PublicFile -> FileDraft +mkPublicFileDraft (PublicFile _pathInPublicDir _publicDirPath) = createCopyFileDraft dstPath srcPath + where + dstPath = webAppRootDirInProjectRootDir publicDirInWebAppRootDir _pathInPublicDir + srcPath = _publicDirPath _pathInPublicDir + publicDirInWebAppRootDir :: Path' (Rel WebAppRootDir) (Dir EF.SourceExternalPublicDir) + publicDirInWebAppRootDir = [reldir|public|] + mkTmplFdWithDstAndData :: Path' (Rel WebAppTemplatesDir) File' -> Path' (Rel WebAppRootDir) File' -> diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/ExternalCodeGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator/ExternalCodeGenerator.hs deleted file mode 100644 index 7f17a18841..0000000000 --- a/waspc/src/Wasp/Generator/WebAppGenerator/ExternalCodeGenerator.hs +++ /dev/null @@ -1,57 +0,0 @@ -module Wasp.Generator.WebAppGenerator.ExternalCodeGenerator - ( extClientCodeGeneratorStrategy, - extSharedCodeGeneratorStrategy, - extClientCodeDirInWebAppSrcDir, - ) -where - -import Data.Maybe (fromJust) -import StrongPath (Dir, Path', Rel, reldir, ()) -import qualified StrongPath as SP -import Wasp.Generator.ExternalCodeGenerator.Common - ( ExternalCodeGeneratorStrategy (..), - GeneratedExternalCodeDir, - castRelPathFromSrcToGenExtCodeDir, - ) -import Wasp.Generator.ExternalCodeGenerator.Js (resolveJsFileWaspImportsForExtCodeDir) -import qualified Wasp.Generator.WebAppGenerator.Common as C -import Wasp.Util.FilePath (removePathPrefix) - -extClientCodeGeneratorStrategy :: ExternalCodeGeneratorStrategy -extClientCodeGeneratorStrategy = mkExtCodeGeneratorStrategy extClientCodeDirInWebAppSrcDir - -extSharedCodeGeneratorStrategy :: ExternalCodeGeneratorStrategy -extSharedCodeGeneratorStrategy = mkExtCodeGeneratorStrategy extSharedCodeDirInWebAppSrcDir - --- | Relative path to the directory where external client code will be generated. --- Relative to web app src dir. -extClientCodeDirInWebAppSrcDir :: Path' (Rel C.WebAppSrcDir) (Dir GeneratedExternalCodeDir) -extClientCodeDirInWebAppSrcDir = [reldir|ext-src|] - --- | Relative path to the directory where external shared code will be generated. --- Relative to web app src dir. -extSharedCodeDirInWebAppSrcDir :: Path' (Rel C.WebAppSrcDir) (Dir GeneratedExternalCodeDir) -extSharedCodeDirInWebAppSrcDir = [reldir|shared|] - -mkExtCodeGeneratorStrategy :: Path' (Rel C.WebAppSrcDir) (Dir GeneratedExternalCodeDir) -> ExternalCodeGeneratorStrategy -mkExtCodeGeneratorStrategy extCodeDirInWebAppSrcDir = - ExternalCodeGeneratorStrategy - { _resolveJsFileWaspImports = resolveJsFileWaspImportsForExtCodeDir (SP.castRel extCodeDirInWebAppSrcDir), - _resolveDstFilePath = resolveDstFilePath - } - where - resolveDstFilePath filePath = - case maybeFilePathInStaticAssetsDir of - Just filePathInStaticAssetsDir -> - C.webAppRootDirInProjectRootDir - C.staticAssetsDirInWebAppDir - fromJust (SP.parseRelFile filePathInStaticAssetsDir) - Nothing -> - C.webAppRootDirInProjectRootDir - C.webAppSrcDirInWebAppRootDir - extCodeDirInWebAppSrcDir - castRelPathFromSrcToGenExtCodeDir filePath - where - maybeFilePathInStaticAssetsDir = removePathPrefix staticAssetsDir (SP.fromRelFile filePath) - - staticAssetsDir = SP.fromRelDir C.staticAssetsDirInWebAppDir diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs b/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs index 68332fa7dc..850967ee6b 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/JsImport.hs @@ -1,20 +1,15 @@ module Wasp.Generator.WebAppGenerator.JsImport where import qualified Data.Aeson as Aeson -import Data.Maybe (fromJust) import StrongPath (Dir, Path, Posix, Rel) -import qualified StrongPath as SP -import Wasp.AppSpec.ExtImport (ExtImport) +import StrongPath.TH (reldirP) +import Wasp.AppSpec.ExtImport (ExtImport (..)) import qualified Wasp.AppSpec.ExtImport as EI import qualified Wasp.Generator.JsImport as GJI import Wasp.Generator.WebAppGenerator.Common (WebAppSrcDir) -import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator (extClientCodeDirInWebAppSrcDir) import Wasp.JsImport ( JsImport, - JsImportIdentifier, - JsImportStatement, ) -import qualified Wasp.JsImport as JI extImportToImportJson :: Path Posix (Rel importLocation) (Dir WebAppSrcDir) -> @@ -24,16 +19,14 @@ extImportToImportJson pathFromImportLocationToSrcDir maybeExtImport = GJI.jsImpo where jsImport = extImportToJsImport pathFromImportLocationToSrcDir <$> maybeExtImport -getJsImportStmtAndIdentifier :: - Path Posix (Rel importLocation) (Dir WebAppSrcDir) -> - EI.ExtImport -> - (JsImportStatement, JsImportIdentifier) -getJsImportStmtAndIdentifier pathFromImportLocationToSrcDir = JI.getJsImportStmtAndIdentifier . extImportToJsImport pathFromImportLocationToSrcDir - extImportToJsImport :: Path Posix (Rel importLocation) (Dir WebAppSrcDir) -> EI.ExtImport -> JsImport extImportToJsImport = GJI.extImportToJsImport webAppExtDir where - webAppExtDir = fromJust (SP.relDirToPosix extClientCodeDirInWebAppSrcDir) + -- filip: read notes in ServerGenerator/JsImport.hs + -- todo(filip): use WaspProjectDirInProjectRootDir (once you add it for + -- Prisma stuff) and other stuff from WebAppGenerator/Common to build this + -- directory. Do the same for the server + webAppExtDir = [reldirP|../../../../src|] diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs index 438f1d60d5..abe4106dab 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs @@ -28,7 +28,7 @@ import qualified Wasp.Generator.ServerGenerator.OperationsRoutesG as ServerOpera import Wasp.Generator.WebAppGenerator.Common (serverRootDirFromWebAppRootDir, toViteImportPath) import qualified Wasp.Generator.WebAppGenerator.Common as C import qualified Wasp.Generator.WebAppGenerator.OperationsGenerator.ResourcesG as Resources -import Wasp.JsImport (JsImportName (JsImportField), getJsImportStmtAndIdentifier, makeJsImport) +import Wasp.JsImport (JsImportName (JsImportField), JsImportPath (RelativeImportPath), getJsImportStmtAndIdentifier, makeJsImport) import Wasp.Util (toUpperFirst, (<++>)) genOperations :: AppSpec -> Generator [FileDraft] @@ -106,7 +106,7 @@ operationTypeData operation = tmplData (operationTypeImportStmt, operationTypeImportIdentifier) = getJsImportStmtAndIdentifier $ - makeJsImport operationImportPath (JsImportField $ toUpperFirst operationName) + makeJsImport (RelativeImportPath operationImportPath) (JsImportField $ toUpperFirst operationName) operationName = AS.Operation.getName operation diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs deleted file mode 100644 index 4e82d4d399..0000000000 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Setup.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Wasp.Generator.WebAppGenerator.Setup - ( installNpmDependencies, - ) -where - -import StrongPath (Abs, Dir, Path', ()) -import Wasp.Generator.Common (ProjectRootDir) -import qualified Wasp.Generator.Job as J -import Wasp.Generator.Job.Process (runNodeCommandAsJob) -import qualified Wasp.Generator.WebAppGenerator.Common as Common - -installNpmDependencies :: Path' Abs (Dir ProjectRootDir) -> J.Job -installNpmDependencies projectDir = do - let webAppDir = projectDir Common.webAppRootDirInProjectRootDir - runNodeCommandAsJob webAppDir "npm" ["install"] J.WebApp diff --git a/waspc/src/Wasp/JsImport.hs b/waspc/src/Wasp/JsImport.hs index 39a0db10f1..51e4eabef8 100644 --- a/waspc/src/Wasp/JsImport.hs +++ b/waspc/src/Wasp/JsImport.hs @@ -3,13 +3,15 @@ module Wasp.JsImport ( JsImport (..), JsImportName (..), + JsImportPath (..), JsImportAlias, - JsImportPath, JsImportIdentifier, JsImportStatement, makeJsImport, applyJsImportAlias, + getImportIdentifier, getJsImportStmtAndIdentifier, + getJsImportStmtAndIdentifierRaw, ) where @@ -32,8 +34,12 @@ data JsImport = JsImport } deriving (Show, Eq, Data) -type JsImportPath = Path Posix (Rel Dir') File' +data JsImportPath + = RelativeImportPath (Path Posix (Rel Dir') File') + | ModuleImportPath (Path Posix (Rel Dir') File') + deriving (Show, Eq, Data) +-- Note (filip): not a fan of so many aliases for regular types type JsImportAlias = String data JsImportName @@ -52,6 +58,11 @@ type JsImportClause = String -- | Represents the full import statement e.g. @import { Name } from "file.js"@ type JsImportStatement = String +getImportIdentifier :: JsImport -> JsImportIdentifier +getImportIdentifier JsImport {_name = name} = case name of + JsImportModule identifier -> identifier + JsImportField identifier -> identifier + makeJsImport :: JsImportPath -> JsImportName -> JsImport makeJsImport importPath importName = JsImport importPath importName Nothing @@ -60,15 +71,26 @@ applyJsImportAlias importAlias jsImport = jsImport {_importAlias = importAlias} getJsImportStmtAndIdentifier :: JsImport -> (JsImportStatement, JsImportIdentifier) getJsImportStmtAndIdentifier (JsImport importPath importName maybeImportAlias) = + getJsImportStmtAndIdentifierRaw filePath importName maybeImportAlias + where + filePath = case importPath of + RelativeImportPath relPath -> normalizePath $ SP.fromRelFileP relPath + ModuleImportPath pathString -> SP.fromRelFileP pathString + normalizePath path = if ".." `isPrefixOf` path then path else "./" ++ path + +-- todo(filip): attempt to simplify how we generate imports. I wanted to generate a +-- module import (e.g., '@ext-src/something') and couldn't do it. This is one of +-- the funtions I implemented while I was trying to pull it off. +getJsImportStmtAndIdentifierRaw :: + FilePath -> + JsImportName -> + Maybe JsImportAlias -> + (JsImportStatement, JsImportIdentifier) +getJsImportStmtAndIdentifierRaw importPath importName maybeImportAlias = (importStatement, importIdentifier) where (importIdentifier, importClause) = jsImportIdentifierAndClause - - importStatement :: JsImportStatement - importStatement = "import " ++ importClause ++ " from '" ++ normalizedPath ++ "'" - where - filePath = SP.fromRelFileP importPath - normalizedPath = if ".." `isPrefixOf` filePath then filePath else "./" ++ filePath + importStatement = "import " ++ importClause ++ " from '" ++ importPath ++ "'" -- First part of import statement based on type of import and alias -- e.g. for import { Name as Alias } from "file.js" it returns ("Alias", "{ Name as Alias }") diff --git a/waspc/src/Wasp/Project/Analyze.hs b/waspc/src/Wasp/Project/Analyze.hs index 1639d30c30..1f799610aa 100644 --- a/waspc/src/Wasp/Project/Analyze.hs +++ b/waspc/src/Wasp/Project/Analyze.hs @@ -1,29 +1,32 @@ module Wasp.Project.Analyze ( analyzeWaspProject, - analyzeWaspFile, + readPackageJsonFile, analyzeWaspFileContent, + findWaspFile, ) where import Control.Arrow (ArrowChoice (left)) +import qualified Data.Aeson as Aeson import Data.List (find, isSuffixOf) import StrongPath (Abs, Dir, File', Path', toFilePath, ()) import qualified Wasp.Analyzer as Analyzer import Wasp.Analyzer.AnalyzeError (getErrorMessageAndCtx) import Wasp.Analyzer.Parser.Ctx (Ctx) import qualified Wasp.AppSpec as AS +import Wasp.AppSpec.PackageJson (PackageJson) import Wasp.AppSpec.Valid (isValidationError, isValidationWarning, validateAppSpec) import Wasp.CompileOptions (CompileOptions) import qualified Wasp.CompileOptions as CompileOptions import qualified Wasp.ConfigFile as CF import Wasp.Error (showCompilerErrorForTerminal) -import qualified Wasp.ExternalCode as ExternalCode import qualified Wasp.Generator.ConfigFile as G.CF -import Wasp.Project.Common (CompileError, CompileWarning, WaspProjectDir) +import Wasp.Project.Common (CompileError, CompileWarning, WaspProjectDir, findFileInWaspProjectDir, packageJsonInWaspProjectDir) import Wasp.Project.Db (makeDevDatabaseUrl) import Wasp.Project.Db.Migrations (findMigrationsDir) import Wasp.Project.Deployment (loadUserDockerfileContents) import Wasp.Project.Env (readDotEnvClient, readDotEnvServer) +import qualified Wasp.Project.ExternalFiles as ExternalFiles import Wasp.Project.Vite (findCustomViteConfigPath) import Wasp.Util (maybeToEither) import qualified Wasp.Util.IO as IOUtil @@ -33,13 +36,18 @@ analyzeWaspProject :: CompileOptions -> IO (Either [CompileError] AS.AppSpec, [CompileWarning]) analyzeWaspProject waspDir options = do - findWaspFile waspDir >>= \case - Left e -> return (Left [e], []) + waspFilePathOrError <- maybeToEither [fileNotFoundMessage] <$> findWaspFile waspDir + case waspFilePathOrError of + Left err -> return (Left err, []) Right waspFilePath -> do analyzeWaspFile waspFilePath >>= \case - Left es -> return (Left es, []) + Left errors -> return (Left errors, []) Right declarations -> - constructAppSpec waspDir options declarations + analyzePackageJsonContent waspDir >>= \case + Left errors -> return (Left errors, []) + Right packageJsonContent -> constructAppSpec waspDir options packageJsonContent declarations + where + fileNotFoundMessage = "Couldn't find the *.wasp file in the " ++ toFilePath waspDir ++ " directory" analyzeWaspFile :: Path' Abs File' -> IO (Either [CompileError] [AS.Decl]) analyzeWaspFile waspFilePath = do @@ -53,17 +61,13 @@ analyzeWaspFileContent = return . left (map getErrorMessageAndCtx) . Analyzer.an constructAppSpec :: Path' Abs (Dir WaspProjectDir) -> CompileOptions -> + PackageJson -> [AS.Decl] -> IO (Either [CompileError] AS.AppSpec, [CompileWarning]) -constructAppSpec waspDir options decls = do - externalServerCodeFiles <- - ExternalCode.readFiles (CompileOptions.externalServerCodeDirPath options) - - let externalClientCodeDirPath = CompileOptions.externalClientCodeDirPath options - externalClientCodeFiles <- ExternalCode.readFiles externalClientCodeDirPath +constructAppSpec waspDir options packageJson decls = do + externalCodeFiles <- ExternalFiles.readCodeFiles waspDir + externalPublicFiles <- ExternalFiles.readPublicFiles waspDir - externalSharedCodeFiles <- - ExternalCode.readFiles (CompileOptions.externalSharedCodeDirPath options) maybeMigrationsDir <- findMigrationsDir waspDir maybeUserDockerfileContents <- loadUserDockerfileContents waspDir configFiles <- CF.discoverConfigFiles waspDir G.CF.configFileRelocationMap @@ -71,14 +75,14 @@ constructAppSpec waspDir options decls = do serverEnvVars <- readDotEnvServer waspDir clientEnvVars <- readDotEnvClient waspDir - let customViteConfigPath = findCustomViteConfigPath externalClientCodeFiles + let customViteConfigPath = findCustomViteConfigPath externalCodeFiles let appSpec = AS.AppSpec { AS.decls = decls, + AS.packageJson = packageJson, AS.waspProjectDir = waspDir, - AS.externalClientFiles = externalClientCodeFiles, - AS.externalServerFiles = externalServerCodeFiles, - AS.externalSharedFiles = externalSharedCodeFiles, + AS.externalCodeFiles = externalCodeFiles, + AS.externalPublicFiles = externalPublicFiles, AS.migrationsDir = maybeMigrationsDir, AS.devEnvVarsServer = serverEnvVars, AS.devEnvVarsClient = clientEnvVars, @@ -98,12 +102,26 @@ constructAppSpec waspDir options decls = do show <$> validationWarnings ) -findWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Either String (Path' Abs File')) +findWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe (Path' Abs File')) findWaspFile waspDir = do files <- fst <$> IOUtil.listDirectory waspDir - return $ maybeToEither "Couldn't find a single *.wasp file." $ (waspDir ) <$> find isWaspFile files + return $ (waspDir ) <$> find isWaspFile files where isWaspFile path = ".wasp" `isSuffixOf` toFilePath path && (length (toFilePath path) > length (".wasp" :: String)) + +analyzePackageJsonContent :: Path' Abs (Dir WaspProjectDir) -> IO (Either [CompileError] PackageJson) +analyzePackageJsonContent waspProjectDir = + findPackageJsonFile >>= \case + Just packageJsonFile -> readPackageJsonFile packageJsonFile + Nothing -> return $ Left [fileNotFoundMessage] + where + fileNotFoundMessage = "couldn't find package.json file in the " ++ toFilePath waspProjectDir ++ " directory" + findPackageJsonFile = findFileInWaspProjectDir waspProjectDir packageJsonInWaspProjectDir + +readPackageJsonFile :: Path' Abs File' -> IO (Either [CompileError] PackageJson) +readPackageJsonFile packageJsonFile = do + byteString <- IOUtil.readFileBytes packageJsonFile + return $ maybeToEither ["Error parsing the package.json file"] $ Aeson.decode byteString diff --git a/waspc/src/Wasp/Project/Common.hs b/waspc/src/Wasp/Project/Common.hs index 4ad3919143..69aeaab6db 100644 --- a/waspc/src/Wasp/Project/Common.hs +++ b/waspc/src/Wasp/Project/Common.hs @@ -1,20 +1,77 @@ module Wasp.Project.Common - ( findFileInWaspProjectDir, + ( WaspProjectDir, + DotWaspDir, + NodeModulesDir, CompileError, CompileWarning, - WaspProjectDir, + findFileInWaspProjectDir, + dotWaspDirInWaspProjectDir, + generatedCodeDirInDotWaspDir, + buildDirInDotWaspDir, + waspProjectDirFromProjectRootDir, + dotWaspRootFileInWaspProjectDir, + dotWaspInfoFileInGeneratedCodeDir, + packageJsonInWaspProjectDir, + nodeModulesDirInWaspProjectDir, + extCodeDirInWaspProjectDir, + extPublicDirInWaspProjectDir, ) where -import StrongPath (Abs, Dir, File', Path', Rel, toFilePath, ()) +import StrongPath (Abs, Dir, File', Path', Rel, reldir, relfile, toFilePath, ()) import System.Directory (doesFileExist) - -data WaspProjectDir -- Root dir of Wasp project, containing source files. +import Wasp.AppSpec.ExternalFiles (SourceExternalCodeDir, SourceExternalPublicDir) +import qualified Wasp.Generator.Common type CompileError = String type CompileWarning = String +data WaspProjectDir -- Root dir of Wasp project, containing source files. + +data NodeModulesDir + +data DotWaspDir -- Here we put everything that wasp generates. + +-- | NOTE: If you change the depth of this path, also update @waspProjectDirFromProjectRootDir@ below. +-- TODO: SHould this be renamed to include word "root"? +dotWaspDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir DotWaspDir) +dotWaspDirInWaspProjectDir = [reldir|.wasp|] + +nodeModulesDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir NodeModulesDir) +nodeModulesDirInWaspProjectDir = [reldir|node_modules|] + +-- | NOTE: If you change the depth of this path, also update @waspProjectDirFromProjectRootDir@ below. +-- TODO: Hm this has different name than it has in Generator. +generatedCodeDirInDotWaspDir :: Path' (Rel DotWaspDir) (Dir Wasp.Generator.Common.ProjectRootDir) +generatedCodeDirInDotWaspDir = [reldir|out|] + +-- | NOTE: If you change the depth of this path, also update @waspProjectDirFromProjectRootDir@ below. +buildDirInDotWaspDir :: Path' (Rel DotWaspDir) (Dir Wasp.Generator.Common.ProjectRootDir) +buildDirInDotWaspDir = [reldir|build|] + +-- | NOTE: This path is calculated from the values of @dotWaspDirInWaspProjectDir@, +-- @generatedCodeDirInDotWaspDir@ and @buildDirInDotWaspDir@., which are the three functions just above. +-- Also, it assumes @generatedCodeDirInDotWaspDir@ and @buildDirInDotWaspDir@ have same depth. +-- If any of those change significantly (their depth), this path should be adjusted. +waspProjectDirFromProjectRootDir :: Path' (Rel Wasp.Generator.Common.ProjectRootDir) (Dir WaspProjectDir) +waspProjectDirFromProjectRootDir = [reldir|../../|] + +dotWaspRootFileInWaspProjectDir :: Path' (Rel WaspProjectDir) File' +dotWaspRootFileInWaspProjectDir = [relfile|.wasproot|] + +dotWaspInfoFileInGeneratedCodeDir :: Path' (Rel Wasp.Generator.Common.ProjectRootDir) File' +dotWaspInfoFileInGeneratedCodeDir = [relfile|.waspinfo|] + +packageJsonInWaspProjectDir :: Path' (Rel WaspProjectDir) File' +packageJsonInWaspProjectDir = [relfile|package.json|] + +extCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir) +extCodeDirInWaspProjectDir = [reldir|src|] + +extPublicDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalPublicDir) +extPublicDirInWaspProjectDir = [reldir|public|] + findFileInWaspProjectDir :: Path' Abs (Dir WaspProjectDir) -> Path' (Rel WaspProjectDir) File' -> diff --git a/waspc/src/Wasp/ExternalCode.hs b/waspc/src/Wasp/Project/ExternalFiles.hs similarity index 57% rename from waspc/src/Wasp/ExternalCode.hs rename to waspc/src/Wasp/Project/ExternalFiles.hs index 40bf95fca0..3519140e9e 100644 --- a/waspc/src/Wasp/ExternalCode.hs +++ b/waspc/src/Wasp/Project/ExternalFiles.hs @@ -1,32 +1,37 @@ -module Wasp.ExternalCode - ( readFiles, +module Wasp.Project.ExternalFiles + ( readPublicFiles, + readCodeFiles, ) where import Data.Maybe (catMaybes) import qualified Data.Text.Lazy as TextL import qualified Data.Text.Lazy.IO as TextL.IO -import StrongPath (Abs, Dir, File', Path', Rel, relfile, ()) +import StrongPath (Abs, Dir, Path', ()) import qualified StrongPath as SP import System.IO.Error (isDoesNotExistError) import UnliftIO.Exception (catch, throwIO) -import Wasp.AppSpec.ExternalCode (File (..), SourceExternalCodeDir) -import qualified Wasp.Util.IO -import Wasp.WaspignoreFile (ignores, readWaspignoreFile) +import Wasp.AppSpec.ExternalFiles (CodeFile (CodeFile), PublicFile (PublicFile)) +import Wasp.Project.Common (WaspProjectDir, extCodeDirInWaspProjectDir, extPublicDirInWaspProjectDir) +import Wasp.Project.Waspignore (getNotIgnoredRelFilePaths, waspIgnorePathInWaspProjectDir) -waspignorePathInExtCodeDir :: Path' (Rel SourceExternalCodeDir) File' -waspignorePathInExtCodeDir = [relfile|.waspignore|] +-- | Returns all files contained in the specified ext public dir +-- except files ignores by the specified waspignore file. +readPublicFiles :: Path' Abs (Dir WaspProjectDir) -> IO [PublicFile] +readPublicFiles waspProjectDir = do + let externalPublicDirPath = waspProjectDir extPublicDirInWaspProjectDir + let waspignoreFilePath = waspProjectDir waspIgnorePathInWaspProjectDir + relFilePaths <- getNotIgnoredRelFilePaths waspignoreFilePath externalPublicDirPath + return $ map (`PublicFile` externalPublicDirPath) relFilePaths --- | Returns all files contained in the specified external code dir, recursively, +-- | Returns all files contained in the specified ext code dir -- except files ignores by the specified waspignore file. -readFiles :: Path' Abs (Dir SourceExternalCodeDir) -> IO [File] -readFiles extCodeDirPath = do - let waspignoreFilePath = extCodeDirPath waspignorePathInExtCodeDir - waspignoreFile <- readWaspignoreFile waspignoreFilePath - relFilePaths <- - filter (not . ignores waspignoreFile . SP.toFilePath) - <$> Wasp.Util.IO.listDirectoryDeep extCodeDirPath - let absFilePaths = map (extCodeDirPath ) relFilePaths +readCodeFiles :: Path' Abs (Dir WaspProjectDir) -> IO [CodeFile] +readCodeFiles waspProjectDir = do + let externalCodeDirPath = waspProjectDir extCodeDirInWaspProjectDir + let waspignoreFilePath = waspProjectDir waspIgnorePathInWaspProjectDir + relFilePaths <- getNotIgnoredRelFilePaths waspignoreFilePath externalCodeDirPath + let absFiles = map (externalCodeDirPath ) relFilePaths -- NOTE: We read text from all the files, regardless if they are text files or not, because -- we don't know if they are a text file or not. -- Since we do lazy reading (Text.Lazy), this is not a problem as long as we don't try to use @@ -40,8 +45,8 @@ readFiles extCodeDirPath = do -- or create new file draft that will support that. -- In generator, when creating TextFileDraft, give it function/logic for text transformation, -- and it will be taken care of when draft will be written to the disk. - fileTexts <- catMaybes <$> mapM (tryReadFile . SP.toFilePath) absFilePaths - let files = zipWith (`File` extCodeDirPath) relFilePaths fileTexts + fileTexts <- catMaybes <$> mapM (tryReadFile . SP.toFilePath) absFiles + let files = zipWith (`CodeFile` externalCodeDirPath) relFilePaths fileTexts return files where -- NOTE(matija): we had cases (e.g. tmp Vim files) where a file initially existed diff --git a/waspc/src/Wasp/Project/Vite.hs b/waspc/src/Wasp/Project/Vite.hs index 6208f4fcfb..12f7809524 100644 --- a/waspc/src/Wasp/Project/Vite.hs +++ b/waspc/src/Wasp/Project/Vite.hs @@ -2,17 +2,18 @@ module Wasp.Project.Vite where import Data.List (find) import StrongPath (File', Path', Rel, relfile) -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) -import qualified Wasp.AppSpec.ExternalCode as ExternalCode +import Wasp.AppSpec.ExternalFiles (SourceExternalCodeDir) +import qualified Wasp.AppSpec.ExternalFiles as ExternalFiles -findCustomViteConfigPath :: [ExternalCode.File] -> Maybe (Path' (Rel SourceExternalCodeDir) File') -findCustomViteConfigPath externalClientCodeFiles = ExternalCode._pathInExtCodeDir <$> maybeCustomViteConfigPath +-- todo(filip): move this +findCustomViteConfigPath :: [ExternalFiles.CodeFile] -> Maybe (Path' (Rel SourceExternalCodeDir) File') +findCustomViteConfigPath externalClientCodeFiles = ExternalFiles._pathInExtCodeDir <$> maybeCustomViteConfigPath where maybeCustomViteConfigPath = find isCustomViteConfig externalClientCodeFiles - isCustomViteConfig :: ExternalCode.File -> Bool + isCustomViteConfig :: ExternalFiles.CodeFile -> Bool isCustomViteConfig - ExternalCode.File + ExternalFiles.CodeFile { _pathInExtCodeDir = path } = path == pathToViteTsConfig || path == pathToViteJsConfig diff --git a/waspc/src/Wasp/WaspignoreFile.hs b/waspc/src/Wasp/Project/Waspignore.hs similarity index 71% rename from waspc/src/Wasp/WaspignoreFile.hs rename to waspc/src/Wasp/Project/Waspignore.hs index 7b45778408..3bcd3e5897 100644 --- a/waspc/src/Wasp/WaspignoreFile.hs +++ b/waspc/src/Wasp/Project/Waspignore.hs @@ -1,19 +1,43 @@ -module Wasp.WaspignoreFile +module Wasp.Project.Waspignore ( WaspignoreFile, parseWaspignoreFile, + getNotIgnoredRelFilePaths, readWaspignoreFile, + waspIgnorePathInWaspProjectDir, ignores, ) where -import StrongPath (Abs, File', Path') +import StrongPath (Abs, Dir, File', Path', Rel) +import qualified StrongPath as SP +import StrongPath.TH (relfile) import System.FilePath.Glob (Pattern, compile, match) import System.IO.Error (isDoesNotExistError) import UnliftIO.Exception (catch, throwIO) +import Wasp.AppSpec.ExternalFiles (SourceExternalCodeDir, SourceExternalPublicDir) +import Wasp.Project.Common import qualified Wasp.Util.IO as IOUtil +class AffectedByWaspignoreFile a + newtype WaspignoreFile = WaspignoreFile [Pattern] +instance AffectedByWaspignoreFile SourceExternalCodeDir + +instance AffectedByWaspignoreFile SourceExternalPublicDir + +getNotIgnoredRelFilePaths :: + AffectedByWaspignoreFile d => + Path' Abs File' -> + Path' Abs (Dir d) -> + IO [Path' (Rel d) File'] +getNotIgnoredRelFilePaths waspignoreFilePath externalDirPath = do + waspignoreFile <- readWaspignoreFile waspignoreFilePath + filter (not . ignores waspignoreFile . SP.toFilePath) <$> IOUtil.listDirectoryDeep externalDirPath + +waspIgnorePathInWaspProjectDir :: Path' (Rel WaspProjectDir) File' +waspIgnorePathInWaspProjectDir = [relfile|.waspignore|] + -- | These patterns are ignored by every 'WaspignoreFile' defaultIgnorePatterns :: [Pattern] defaultIgnorePatterns = map compile [".waspignore"] diff --git a/waspc/src/Wasp/Project/WebApp.hs b/waspc/src/Wasp/Project/WebApp.hs deleted file mode 100644 index aba8bf692a..0000000000 --- a/waspc/src/Wasp/Project/WebApp.hs +++ /dev/null @@ -1,9 +0,0 @@ -module Wasp.Project.WebApp where - -import StrongPath (Dir, Path', Rel, reldir) -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) - -data StaticAssetsDir - -staticAssetsDirInExtClientCodeDir :: Path' (Rel SourceExternalCodeDir) (Dir StaticAssetsDir) -staticAssetsDirInExtClientCodeDir = [reldir|public|] diff --git a/waspc/src/Wasp/Util/IO.hs b/waspc/src/Wasp/Util/IO.hs index 35d1e5e077..157c322a3b 100644 --- a/waspc/src/Wasp/Util/IO.hs +++ b/waspc/src/Wasp/Util/IO.hs @@ -6,18 +6,22 @@ module Wasp.Util.IO deleteDirectoryIfExists, deleteFileIfExists, doesFileExist, + doesDirectoryExist, readFile, readFileStrict, writeFile, removeFile, + removeDirectory, tryReadFile, isDirectoryEmpty, writeFileFromText, + readFileBytes, ) where -import Control.Monad (filterM, when) +import Control.Monad (filterM) import Control.Monad.Extra (whenM) +import qualified Data.ByteString.Lazy as B import Data.Text (Text) import qualified Data.Text.IO as T.IO import qualified Data.Text.IO as Text.IO @@ -83,10 +87,8 @@ listDirectory absDirPath = do -- with relative paths, define a new function (e.g., `readFileRel`). deleteDirectoryIfExists :: Path' Abs (Dir d) -> IO () -deleteDirectoryIfExists dirPath = do - let dirPathStr = SP.fromAbsDir dirPath - exists <- SD.doesDirectoryExist dirPathStr - when exists $ SD.removeDirectoryRecursive dirPathStr +deleteDirectoryIfExists dirPath = + whenM (doesDirectoryExist dirPath) (removeDirectory dirPath) deleteFileIfExists :: Path' Abs (File f) -> IO () deleteFileIfExists filePath = whenM (doesFileExist filePath) $ removeFile filePath @@ -94,9 +96,15 @@ deleteFileIfExists filePath = whenM (doesFileExist filePath) $ removeFile filePa doesFileExist :: Path' Abs (File f) -> IO Bool doesFileExist = SD.doesFileExist . SP.fromAbsFile +doesDirectoryExist :: Path' Abs (Dir f) -> IO Bool +doesDirectoryExist = SD.doesDirectoryExist . SP.fromAbsDir + readFile :: Path' Abs (File f) -> IO String readFile = P.readFile . SP.fromAbsFile +readFileBytes :: Path' Abs (File f) -> IO B.ByteString +readFileBytes = B.readFile . SP.fromAbsFile + readFileStrict :: Path' Abs (File f) -> IO Text readFileStrict = T.IO.readFile . SP.toFilePath @@ -109,6 +117,9 @@ writeFileFromText = T.IO.writeFile . SP.fromAbsFile removeFile :: Path' Abs (File f) -> IO () removeFile = SD.removeFile . SP.fromAbsFile +removeDirectory :: Path' Abs (Dir d) -> IO () +removeDirectory = SD.removeDirectoryRecursive . SP.fromAbsDir + tryReadFile :: FilePath -> IO (Maybe Text) tryReadFile fp = (Just <$> Text.IO.readFile fp) diff --git a/waspc/src/Wasp/Util/Terminal.hs b/waspc/src/Wasp/Util/Terminal.hs index c9a17a629e..0713cf65a3 100644 --- a/waspc/src/Wasp/Util/Terminal.hs +++ b/waspc/src/Wasp/Util/Terminal.hs @@ -18,6 +18,14 @@ data Style | Magenta | Cyan | White + | BlackBg + | RedBg + | GreenBg + | YellowBg + | BlueBg + | MagentaBg + | CyanBg + | WhiteBg | Bold | Underline | Blink @@ -41,6 +49,14 @@ styleCode Blue = "[34m" styleCode Magenta = "[35m" styleCode Cyan = "[36m" styleCode White = "[37m" +styleCode BlackBg = "[40m" +styleCode RedBg = "[41m" +styleCode GreenBg = "[42m" +styleCode YellowBg = "[43m" +styleCode BlueBg = "[44m" +styleCode MagentaBg = "[45m" +styleCode CyanBg = "[46m" +styleCode WhiteBg = "[47m" styleCode Bold = "[1m" styleCode Underline = "[4m" styleCode Blink = "[5m" -- Blink does not work in all terminal emulators (e.g. on mac in iTerm2). diff --git a/waspc/test/AI/GenerateNewProject/PageComponentFileTest.hs b/waspc/test/AI/GenerateNewProject/PageComponentFileTest.hs index 99c7b39c7a..000b8e4207 100644 --- a/waspc/test/AI/GenerateNewProject/PageComponentFileTest.hs +++ b/waspc/test/AI/GenerateNewProject/PageComponentFileTest.hs @@ -9,7 +9,7 @@ spec_PageComponentFileTest :: Spec spec_PageComponentFileTest = do describe "getPageComponentFileContentWithFixedImports" $ do let mockAllPossibleWaspClientImports = - M.fromList $ + M.fromList [ ("useQuery", "import { useQuery } from '@wasp/queries';"), ("useAction", "import { useAction } from '@wasp/actions';"), ("useAuth", "import useAuth from '@wasp/auth/useAuth';"), diff --git a/waspc/test/AnalyzerTest.hs b/waspc/test/AnalyzerTest.hs index a82f4ed41a..4c33aa30cb 100644 --- a/waspc/test/AnalyzerTest.hs +++ b/waspc/test/AnalyzerTest.hs @@ -9,6 +9,7 @@ import Data.List (intercalate) import Data.Maybe (fromJust) import qualified StrongPath as SP import Test.Tasty.Hspec +import qualified Wasp.AI.GenerateNewProject.Common as Auth import Wasp.Analyzer import Wasp.Analyzer.Parser (Ctx) import qualified Wasp.Analyzer.TypeChecker as TC @@ -48,11 +49,12 @@ spec_Analyzer = do " head: [\"foo\", \"bar\"],", " auth: {", " userEntity: User,", - " methods: { usernameAndPassword: {} },", - " onAuthFailedRedirectTo: \"/\",", - " signup: {", - " additionalFields: import { fields } from \"@server/auth/signup.js\",", + " methods: {", + " usernameAndPassword: {", + " userSignupFields: import { getUserFields } from \"@server/auth/signup.js\",", + " }", " },", + " onAuthFailedRedirectTo: \"/\",", " },", " dependencies: [", " (\"redux\", \"^4.0.5\")", @@ -139,15 +141,13 @@ spec_Analyzer = do Auth.Auth { Auth.userEntity = Ref "User" :: Ref Entity, Auth.externalAuthEntity = Nothing, - Auth.signup = - Just $ - Auth.SignupOptions - { Auth.additionalFields = - Just $ ExtImport (ExtImportField "fields") (fromJust $ SP.parseRelFileP "auth/signup.js") - }, Auth.methods = Auth.AuthMethods - { Auth.usernameAndPassword = Just Auth.usernameAndPasswordConfig, + { Auth.usernameAndPassword = + Just + Auth.UsernameAndPasswordConfig + { Auth.userSignupFields = Just $ ExtImport (ExtImportField "getUserFields") (fromJust $ SP.parseRelFileP "auth/signup.js") + }, Auth.google = Nothing, Auth.gitHub = Nothing, Auth.email = Nothing diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index 29863a02ce..5c789aee31 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -99,14 +99,13 @@ spec_AppSpecValid = do AS.Auth.externalAuthEntity = Nothing, AS.Auth.methods = AS.Auth.AuthMethods - { AS.Auth.usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, + { AS.Auth.usernameAndPassword = Just AS.Auth.UsernameAndPasswordConfig {AS.Auth.userSignupFields = Nothing}, AS.Auth.google = Nothing, AS.Auth.gitHub = Nothing, AS.Auth.email = Nothing }, AS.Auth.onAuthFailedRedirectTo = "/", - AS.Auth.onAuthSucceededRedirectTo = Nothing, - AS.Auth.signup = Nothing + AS.Auth.onAuthSucceededRedirectTo = Nothing } describe "should validate that when a page has authRequired, app.auth is also set." $ do @@ -149,8 +148,7 @@ spec_AppSpecValid = do AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, AS.Auth.externalAuthEntity = Nothing, AS.Auth.onAuthFailedRedirectTo = "/", - AS.Auth.onAuthSucceededRedirectTo = Nothing, - AS.Auth.signup = Nothing + AS.Auth.onAuthSucceededRedirectTo = Nothing }, AS.App.emailSender = Just @@ -166,7 +164,8 @@ spec_AppSpecValid = do } let emailAuthConfig = AS.Auth.EmailAuthConfig - { AS.Auth.fromField = + { AS.Auth.userSignupFields = Nothing, + AS.Auth.fromField = AS.EmailSender.EmailFromField { AS.EmailSender.email = "dummy@info.com", AS.EmailSender.name = Nothing @@ -180,19 +179,47 @@ spec_AppSpecValid = do AS.Auth.PasswordReset.PasswordResetConfig { AS.Auth.PasswordReset.clientRoute = AS.Core.Ref.Ref basicRouteName, AS.Auth.PasswordReset.getEmailContentFn = Nothing - }, - AS.Auth.allowUnverifiedLogin = Nothing + } } it "returns no error if app.auth is not set" $ do ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing, email = Nothing}) validUserEntity) `shouldBe` [] it "returns no error if app.auth is set and only one of UsernameAndPassword and Email is used" $ do - ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, google = Nothing, gitHub = Nothing, email = Nothing}) validUserEntity) `shouldBe` [] + ASV.validateAppSpec + ( makeSpec + ( AS.Auth.AuthMethods + { usernameAndPassword = + Just + AS.Auth.UsernameAndPasswordConfig + { AS.Auth.userSignupFields = Nothing + }, + google = Nothing, + gitHub = Nothing, + email = Nothing + } + ) + validUserEntity + ) + `shouldBe` [] ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing, email = Just emailAuthConfig}) validUserEntity) `shouldBe` [] it "returns an error if app.auth is set and both UsernameAndPassword and Email are used" $ do - ASV.validateAppSpec (makeSpec (AS.Auth.AuthMethods {usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, google = Nothing, gitHub = Nothing, email = Just emailAuthConfig}) validUserEntity) + ASV.validateAppSpec + ( makeSpec + ( AS.Auth.AuthMethods + { usernameAndPassword = + Just + AS.Auth.UsernameAndPasswordConfig + { AS.Auth.userSignupFields = Nothing + }, + google = Nothing, + gitHub = Nothing, + email = Just emailAuthConfig + } + ) + validUserEntity + ) `shouldContain` [ASV.GenericValidationError "Expected app.auth to use either email or username and password authentication, but not both."] describe "should validate that when app.auth is using UsernameAndPassword, user entity is of valid shape." $ do @@ -220,6 +247,77 @@ spec_AppSpecValid = do `shouldBe` [ ASV.GenericValidationError "Entity 'User' (referenced by app.auth.userEntity) must have an ID field (specified with the '@id' attribute)" ] + + describe "should validate email sender setup." $ do + let emailAuthConfig = + AS.Auth.EmailAuthConfig + { AS.Auth.userSignupFields = Nothing, + AS.Auth.fromField = + AS.EmailSender.EmailFromField + { AS.EmailSender.email = "dummy@info.com", + AS.EmailSender.name = Nothing + }, + AS.Auth.emailVerification = + AS.Auth.EmailVerification.EmailVerificationConfig + { AS.Auth.EmailVerification.clientRoute = AS.Core.Ref.Ref basicRouteName, + AS.Auth.EmailVerification.getEmailContentFn = Nothing + }, + AS.Auth.passwordReset = + AS.Auth.PasswordReset.PasswordResetConfig + { AS.Auth.PasswordReset.clientRoute = AS.Core.Ref.Ref basicRouteName, + AS.Auth.PasswordReset.getEmailContentFn = Nothing + } + } + + let makeSpec emailSender isBuild = + basicAppSpec + { AS.isBuild = isBuild, + AS.decls = + [ AS.Decl.makeDecl "TestApp" $ + basicApp + { AS.App.auth = + Just + AS.Auth.Auth + { AS.Auth.methods = + AS.Auth.AuthMethods {email = Just emailAuthConfig, usernameAndPassword = Nothing, google = Nothing, gitHub = Nothing}, + AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, + AS.Auth.externalAuthEntity = Nothing, + AS.Auth.onAuthFailedRedirectTo = "/", + AS.Auth.onAuthSucceededRedirectTo = Nothing + }, + AS.App.emailSender = emailSender + }, + AS.Decl.makeDecl userEntityName $ + AS.Entity.makeEntity + ( PslM.Body + [ PslM.ElementField $ makeIdField "id" PslM.String + ] + ), + basicPageDecl, + basicRouteDecl + ] + } + let mailgunEmailSender = + AS.EmailSender.EmailSender + { AS.EmailSender.provider = AS.EmailSender.Mailgun, + AS.EmailSender.defaultFrom = Nothing + } + + let dummyEmailSender = + AS.EmailSender.EmailSender + { AS.EmailSender.provider = AS.EmailSender.Dummy, + AS.EmailSender.defaultFrom = Nothing + } + + it "returns an error if no email sender is set but email auth is used" $ do + ASV.validateAppSpec (makeSpec Nothing False) `shouldBe` [ASV.GenericValidationError "app.emailSender must be specified when using email auth. You can use the Dummy email sender for development purposes."] + it "returns no error if email sender is defined while using email auth" $ do + ASV.validateAppSpec (makeSpec (Just mailgunEmailSender) False) `shouldBe` [] + it "returns no error if the Dummy email sender is used in development" $ do + ASV.validateAppSpec (makeSpec (Just dummyEmailSender) False) `shouldBe` [] + it "returns an error if the Dummy email sender is used when building the app" $ do + ASV.validateAppSpec (makeSpec (Just dummyEmailSender) True) + `shouldBe` [ASV.GenericValidationError "app.emailSender must not be set to Dummy when building for production."] where makeIdField name typ = PslM.Field @@ -258,9 +356,8 @@ spec_AppSpecValid = do AS.AppSpec { AS.decls = [basicAppDecl], AS.waspProjectDir = systemSPRoot SP. [SP.reldir|test/|], - AS.externalClientFiles = [], - AS.externalServerFiles = [], - AS.externalSharedFiles = [], + AS.externalCodeFiles = [], + AS.externalPublicFiles = [], AS.isBuild = False, AS.migrationsDir = Nothing, AS.devEnvVarsClient = [], diff --git a/waspc/test/Generator/ExternalCodeGenerator/JsTest.hs b/waspc/test/Generator/ExternalCodeGenerator/JsTest.hs deleted file mode 100644 index 00228bffd9..0000000000 --- a/waspc/test/Generator/ExternalCodeGenerator/JsTest.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Generator.ExternalCodeGenerator.JsTest where - -import qualified StrongPath as SP -import Test.Tasty.Hspec -import Wasp.Generator.ExternalCodeGenerator.Common (asGenExtFile) -import Wasp.Generator.ExternalCodeGenerator.Js as Js - -spec_resolveJsFileWaspImportsForExtCodeDir :: Spec -spec_resolveJsFileWaspImportsForExtCodeDir = do - (asGenExtFile [SP.relfile|extFile.js|], "import foo from 'bar'") ~> "import foo from 'bar'" - (asGenExtFile [SP.relfile|extFile.js|], "import foo from '@wasp/bar'") ~> "import foo from '../bar'" - (asGenExtFile [SP.relfile|a/extFile.js|], "import foo from \"@wasp/bar/foo\"") - ~> "import foo from \"../../bar/foo\"" - where - (path, text) ~> expectedText = - it (SP.toFilePath path ++ " " ++ show text ++ " -> " ++ show expectedText) $ do - Js.resolveJsFileWaspImportsForExtCodeDir [SP.reldir|src|] path text `shouldBe` expectedText diff --git a/waspc/test/Generator/WebAppGeneratorTest.hs b/waspc/test/Generator/WebAppGeneratorTest.hs index 145e6e493e..a4f5cc7f0b 100644 --- a/waspc/test/Generator/WebAppGeneratorTest.hs +++ b/waspc/test/Generator/WebAppGeneratorTest.hs @@ -46,9 +46,8 @@ spec_WebAppGenerator = do } ], AS.waspProjectDir = systemSPRoot SP. [SP.reldir|test/|], - AS.externalClientFiles = [], - AS.externalServerFiles = [], - AS.externalSharedFiles = [], + AS.externalCodeFiles = [], + AS.externalPublicFiles = [], AS.isBuild = False, AS.migrationsDir = Nothing, AS.devEnvVarsServer = [], diff --git a/waspc/test/WaspignoreFileTest.hs b/waspc/test/Project/WaspignoreTest.hs similarity index 94% rename from waspc/test/WaspignoreFileTest.hs rename to waspc/test/Project/WaspignoreTest.hs index 3392aa1de1..eedd110100 100644 --- a/waspc/test/WaspignoreFileTest.hs +++ b/waspc/test/Project/WaspignoreTest.hs @@ -1,8 +1,8 @@ -module WaspignoreFileTest where +module Project.WaspignoreTest where import Test.Tasty.Hspec import Test.Tasty.QuickCheck (arbitraryPrintableChar, forAll, listOf, property) -import Wasp.WaspignoreFile (ignores, parseWaspignoreFile) +import Wasp.Project.Waspignore (ignores, parseWaspignoreFile) spec_IgnoreFile :: Spec spec_IgnoreFile = do diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index dc72fc8e6f..b44a54f371 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -238,11 +238,12 @@ library Wasp.AppSpec.Core.Ref Wasp.AppSpec.Entity Wasp.AppSpec.Entity.Field - Wasp.AppSpec.ExternalCode + Wasp.AppSpec.ExternalFiles Wasp.AppSpec.ExtImport Wasp.AppSpec.Job Wasp.AppSpec.JSON Wasp.AppSpec.Operation + Wasp.AppSpec.PackageJson Wasp.AppSpec.Page Wasp.AppSpec.Query Wasp.AppSpec.Route @@ -254,7 +255,6 @@ library Wasp.Db.Postgres Wasp.Error Wasp.Env - Wasp.ExternalCode Wasp.Generator Wasp.Generator.AuthProviders Wasp.Generator.AuthProviders.Common @@ -272,9 +272,7 @@ library Wasp.Generator.DbGenerator.Jobs Wasp.Generator.DbGenerator.Operations Wasp.Generator.DockerGenerator - Wasp.Generator.ExternalCodeGenerator Wasp.Generator.ExternalCodeGenerator.Common - Wasp.Generator.ExternalCodeGenerator.Js Wasp.Generator.FileDraft Wasp.Generator.FileDraft.CopyDirFileDraft Wasp.Generator.FileDraft.CopyFileDraft @@ -292,6 +290,9 @@ library Wasp.Generator.Monad Wasp.Generator.NpmDependencies Wasp.Generator.NpmInstall + Wasp.Generator.SdkGenerator + Wasp.Generator.SdkGenerator.Common + Wasp.Generator.SdkGenerator.ServerOpsGenerator Wasp.Generator.ServerGenerator Wasp.Generator.ServerGenerator.JsImport Wasp.Generator.ServerGenerator.ApiRoutesG @@ -303,12 +304,9 @@ library Wasp.Generator.ServerGenerator.EmailSenderG Wasp.Generator.ServerGenerator.EmailSender.Providers Wasp.Generator.ServerGenerator.Common - Wasp.Generator.ServerGenerator.ConfigG - Wasp.Generator.ServerGenerator.ExternalCodeGenerator Wasp.Generator.ServerGenerator.JobGenerator Wasp.Generator.ServerGenerator.OperationsG Wasp.Generator.ServerGenerator.OperationsRoutesG - Wasp.Generator.ServerGenerator.Setup Wasp.Generator.ServerGenerator.Start Wasp.Generator.ServerGenerator.WebSocketG Wasp.Generator.ServerGenerator.CrudG @@ -325,11 +323,9 @@ library Wasp.Generator.WebAppGenerator.Auth.EmailAuthG Wasp.Generator.WebAppGenerator.Auth.Common Wasp.Generator.WebAppGenerator.Common - Wasp.Generator.WebAppGenerator.ExternalCodeGenerator Wasp.Generator.WebAppGenerator.OperationsGenerator Wasp.Generator.WebAppGenerator.OperationsGenerator.ResourcesG Wasp.Generator.WebAppGenerator.RouterGenerator - Wasp.Generator.WebAppGenerator.Setup Wasp.Generator.WebAppGenerator.Start Wasp.Generator.WebAppGenerator.Test Wasp.Generator.WebSocket @@ -349,7 +345,7 @@ library Wasp.Project.Db.Dev.Postgres Wasp.Project.Deployment Wasp.Project.Env - Wasp.Project.WebApp + Wasp.Project.ExternalFiles Wasp.Project.Studio Wasp.Project.Vite Wasp.Psl.Ast.Model @@ -378,7 +374,7 @@ library Wasp.Util.StrongPath Wasp.Util.WebRouterPath Wasp.Version - Wasp.WaspignoreFile + Wasp.Project.Waspignore library waspls import: common-all @@ -492,13 +488,11 @@ library cli-lib Wasp.Cli.Command.CreateNewProject.Common Wasp.Cli.Command.CreateNewProject.ProjectDescription Wasp.Cli.Command.CreateNewProject.StarterTemplates + Wasp.Cli.Command.CreateNewProject.StarterTemplates.GhRepo Wasp.Cli.Command.CreateNewProject.StarterTemplates.Local - Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote - Wasp.Cli.Command.CreateNewProject.StarterTemplates.Remote.Github Wasp.Cli.Command.CreateNewProject.StarterTemplates.Templating Wasp.Cli.Command.Db Wasp.Cli.Command.Db.Migrate - Wasp.Cli.Command.Db.Reset Wasp.Cli.Command.Db.Seed Wasp.Cli.Command.Db.Studio Wasp.Cli.Command.Deps @@ -587,7 +581,6 @@ test-suite waspc-test FilePath.ExtraTest Fixtures Generator.DbGeneratorTest - Generator.ExternalCodeGenerator.JsTest Generator.FileDraft.CopyFileDraftTest Generator.FileDraft.CopyAndModifyTextFileDraftTest Generator.FileDraft.TemplateFileDraftTest @@ -611,7 +604,7 @@ test-suite waspc-test SemanticVersionTest SemanticVersion.VersionBoundTest SemanticVersion.VersionTest - WaspignoreFileTest + Project.WaspignoreTest Paths_waspc Generator.NpmDependenciesTest JsImportTest diff --git a/waspc/waspls/src/Wasp/LSP/Diagnostic.hs b/waspc/waspls/src/Wasp/LSP/Diagnostic.hs index b5b3de03d7..09e853d345 100644 --- a/waspc/waspls/src/Wasp/LSP/Diagnostic.hs +++ b/waspc/waspls/src/Wasp/LSP/Diagnostic.hs @@ -119,5 +119,5 @@ waspErrorRange err = clearMissingExtImportDiagnostics :: [WaspDiagnostic] -> [WaspDiagnostic] clearMissingExtImportDiagnostics = filter (not . isMissingImportDiagnostic) where - isMissingImportDiagnostic (MissingExtImportDiagnostic _ _ _) = True + isMissingImportDiagnostic MissingExtImportDiagnostic {} = True isMissingImportDiagnostic _ = False diff --git a/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs b/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs index d1c4882d96..2ea3bd9d6e 100644 --- a/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs +++ b/waspc/waspls/src/Wasp/LSP/ExtImport/Path.hs @@ -24,7 +24,7 @@ import GHC.Generics (Generic) import qualified Path as P import qualified StrongPath as SP import qualified StrongPath.Path as SP -import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir) +import Wasp.AppSpec.ExternalFiles (SourceExternalCodeDir) import Wasp.LSP.ServerMonads.HasProjectRootDir (HasProjectRootDir (getProjectRootDir)) import Wasp.Project.Common (WaspProjectDir) import Wasp.Util.IO (doesFileExist) @@ -67,6 +67,7 @@ waspStylePathToCachePath (WaspStyleExtFilePath waspStylePath) = then ExtFileCachePath relPathWithoutExt (DotExact ext) else ExtFileCachePath relPathWithoutExt (widenExtension ext) where + -- Filip: todo - update for new structure useExactExtension = "@client" `isPrefixOf` waspStylePath absPathToCachePath :: HasProjectRootDir m => SP.Path' SP.Abs (SP.File a) -> m (Maybe ExtFileCachePath) diff --git a/waspc/waspls/src/Wasp/LSP/ServerMonads.hs b/waspc/waspls/src/Wasp/LSP/ServerMonads.hs index 8e4b93de39..5bfa012fb3 100644 --- a/waspc/waspls/src/Wasp/LSP/ServerMonads.hs +++ b/waspc/waspls/src/Wasp/LSP/ServerMonads.hs @@ -1,6 +1,5 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE TypeSynonymInstances #-} module Wasp.LSP.ServerMonads ( -- * LSP Server Monads @@ -106,6 +105,8 @@ sendToReactor act = do rin <- handler $ asks (^. reactorIn) liftIO $ atomically $ writeTChan rin $ ReactorAction $ LSP.runLspT env $ runRLspM stateTVar act +{- HLINT ignore "Redundant <$>" -} + instance HasProjectRootDir HandlerM where -- Returns the folder that contains the active .wasp file, which is assumed -- to be the root of the wasp project. diff --git a/web/README.md b/web/README.md index ecdd5f8b38..9b4dba2e90 100644 --- a/web/README.md +++ b/web/README.md @@ -2,6 +2,12 @@ This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. +It consists of three main parts: + - Landing page ([src/pages/index.js](src/pages/index.js)) + - Blog ([blog/](blog/)) + - Docs ([docs/](docs/)) + + ### Installation ``` @@ -14,7 +20,8 @@ $ npm install $ npm start ``` -This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. +This command starts a local development server and opens up a browser window. +Most changes are reflected live without having to restart the server. ### Build @@ -36,4 +43,52 @@ First, ensure you are on the `release` branch. Next, run: $ GIT_USER= USE_SSH=true npm run deploy ``` -This command will build the website and push it to the `gh-pages` branch. +This command will build the website and push it to the `gh-pages` branch, +which will get it deployed to https://wasp-lang.dev ! + +### Multiple documentation versions + +We maintain docs for multiple versions of Wasp. + +Docusaurus docs on this: https://docusaurus.io/docs/versioning . + +Docusaurus recognizes "current" docs, which are docs in ./docs dir, and also +individual versioned docs, which are docs under versioned_docs/{version}/. +So we have 1 "current" docs and a number of versioned docs. + +We stick with Docusaurus' recommended/default approach for multiple doc versions, which says that "current" docs, which reside in `docs/` dir and are served under `/docs/next` URL, are work in progress (even though they are named "current", which is a bit misleading). +So "current" docs are docs for the version of Wasp that is currently in development, not for the last released version, and they are not meant to be consumed by typical Wasp user. + +Each versioned documentation consists of versioned_docs/{version} and +versioned_sidebars/{version}. +There is also versions.json file which is just a list of versioned docs. + +By default, "current" docs are served under URL {baseUrl}/docs/next, +each versioned doc is served under URL {baseUrl}/docs/{version}, +and last versioned docs (first version in versions.json) +are served under URL {baseUrl}/docs/, as the default/latest docs. + +Since we don't want our users to read `docs/next` ("current" docs), we don't publish these when we deploy docs, instead we build them only during development. + +#### When/how do we create new version of docs from "current" docs? + +When releasing new version of Wasp, what we do is run `npm run docusaurus docs:version {version}` to create new versioned docs from the current docs. We do this on every new Wasp release. + +This command does everything for us, and since we use Docusaurus' default settings for versions, +there is nothing else we need to do, it will be picked up as the lastest version by default. + +#### Which version of docs should I be editing? + +If you are writing/updating docs on `main` for the new release of Wasp, you should edit "current" docs (docs/). + +If you are (hot)fixing currently published docs on `release`, then you should edit the versioned docs (versioned_docs/{version}), for whatever version you want to do this for. If you want this change to also be present in all the new docs, then you should also do it for the "current" docs (docs/) (yes, that means duplicating the same change). + +Prefer doing doc edits on `main`, as that keeps the whole process simpler, and do changes to docs on `release` only if it really matters to fix the already published versioned docs. + +#### Deleting versions + +We should not keep too many versions of documentation, especially now in Beta when we are moving fast. + +Therefore, we should be quite liberal with deleting the older versions of docs. + +Also, it might make sense to delete the previous version of docs if only bug fixes were done in the latest version. diff --git a/web/blog/2023-03-02-wasp-beta-update-feb.md b/web/blog/2023-03-02-wasp-beta-update-feb.md index fa59b96848..ca23a5f36e 100644 --- a/web/blog/2023-03-02-wasp-beta-update-feb.md +++ b/web/blog/2023-03-02-wasp-beta-update-feb.md @@ -60,7 +60,7 @@ This is one of the features we are most excited about! Now, when you define an e This feature beautifully showcases the power of the Wasp language approach and how much it can cut down on the boilerplate. And we're just getting started! -For more details, [check out our docs on reusing entity types on both a client and a server](/docs/typescript#entity-types). +For more details, [check out our entity docs](/docs/data-model/entities). ## 🗓 We set a date for the next launch - April 11th! 🚀 diff --git a/web/blog/2023-08-09-build-real-time-voting-app-websockets-react-typescript.md b/web/blog/2023-08-09-build-real-time-voting-app-websockets-react-typescript.md index f7f23187a3..6274394a5e 100644 --- a/web/blog/2023-08-09-build-real-time-voting-app-websockets-react-typescript.md +++ b/web/blog/2023-08-09-build-real-time-voting-app-websockets-react-typescript.md @@ -710,7 +710,7 @@ You should see a login screen this time. Go ahead and first register a user, the Once logged in, you’ll see the same hardcoded poll data as in the previous example, because, again, we haven’t set up the [Socket.IO](http://Socket.IO) client on the frontend. But this time it should be much easier. -Why? Well, besides less configuration, another nice benefit of working with [TypeScript with Wasp](/docs/typescript#websocket-full-stack-type-support), is that you just have to define payload types with matching event names on the server, and those types will get exposed automatically on the client! +Why? Well, besides less configuration, another nice benefit of working with [TypeScript with Wasp](/docs/advanced/web-sockets), is that you just have to define payload types with matching event names on the server, and those types will get exposed automatically on the client! Let’s take a look at how that works now. @@ -885,4 +885,4 @@ And if you know of a better, cooler, sleeker way of implementing WebSockets into \ No newline at end of file +/> diff --git a/web/blog/2023-11-21-guide-windows-development-wasp-wsl.md b/web/blog/2023-11-21-guide-windows-development-wasp-wsl.md new file mode 100644 index 0000000000..92f0b2a0da --- /dev/null +++ b/web/blog/2023-11-21-guide-windows-development-wasp-wsl.md @@ -0,0 +1,189 @@ +--- +title: 'A Guide to Windows Development with Wasp & WSL' +authors: [martinovicdev] +image: /img/wsl-guide/wsl-guide-banner.jpeg +tags: [wsl, windows, tutorial] +--- + +import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; + +import ImgWithCaption from './components/ImgWithCaption' + + + +If you are having a hard time with Wasp development on Windows, don't be afraid! We will go through all necessary steps to set up your dev environment and get you started with Wasp development in Windows in no time. + +## What is WSL and why should I be interested in it? + +Windows Subsystem for Linux (or WSL) lets developers run a fully functional and native GNU/Linux environment directly on Windows. In other words, we can run Linux directly without using a virtual machine or dual-booting the system. + +**The first cool thing about it is that WSL allows you to never switch OS’s, but still have the best of both worlds inside your OS.** +What does that mean for us regular users? When you look at the way WSL works in practice, it can be considered a Windows feature that runs a Linux OS directly inside Windows 10 or 11, with a fully functional Linux file system, Linux command line tools, and Linux GUI apps (_really cool, btw_). Besides that, it uses much fewer resources for running when compared to a virtual machine and also doesn’t require a separate tool for creating and managing those virtual machines. + +WSL is mainly catered to developers, so this article will be focused on developer usage and how to set up a fully working dev environment with VS Code. Inside this article, we’ll go through some of the cool features and how they can be used in practice. Plus, the best way to understand new things is to actually start using them. + +## Installing WSL on the Windows operating system + +In order to install WSL on your Windows, first enable [Hyper-V](https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v) architecture is Microsoft’s hardware virtualization solution. To install it, right-click on the Windows Terminal/Powershell and open it in Administrator mode. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6wm5xniz2nehrccczeh6.png) + +Then, run the following command: + +```bash +Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All +``` + +That will ensure that you have all the prerequisites for the installation. Then, open the Powershell (best done in Windows Terminal) in the Administrator mode. Then, run + +```bash +wsl —install +``` + +There is a plethora of Linux distributions to be installed, but Ubuntu is the one installed by default. This guide will feature many console commands, but most of them will be a copy-paste process. + +If you have installed Docker before, there is a decent chance that you have WSL 2 installed on your system already. In that case, you will get a prompt to install the distribution of your choice. Since this tutorial will be using Ubuntu, I suggest running. + +```bash + wsl --install -d Ubuntu +``` + +After installing Ubuntu (or another distro of your choice), you will enter your Linux OS and be prompted with a welcome screen. There, you will enter some basic info. First, you will enter your username and after that your password. Both of those will be Linux-specific, so you don’t necessarily have to repeat your Windows credentials. After we’ve done this, the installation part is over! You have successfully installed Ubuntu on your Windows machine! It still feels weird to say this, right? + +### Cool WSL featues to help you along the way + +But before we get down to our dev environment setup, I want to show you a couple of cool tricks that will make your life easier and help you understand why WSL is actually a game-changer for Windows users. + +The first cool thing with WSL is that you don’t have to give up the current way of managing files through Windows Explorer. In your sidebar in Windows Explorer, you can find the Linux option now right under the network tab. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/647jdnzilrucsijtye3v.png) + +From there, you can access and manage your Linux OS’s file system directly from the Windows Explorer. What is really cool with this feature is that you can basically copy, paste, and move files between different operating systems without any issues, which opens up a whole world of possibilities. Effectively, you don’t have to change much in your workflow with files and you can move many projects and files from one OS to another effortlessly. If you download an image for your web app on your Windows browser, just copy and paste it to your Linux OS. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iqjsd1oz5a4alu6q08re.png) + +Another very important thing, which we will use in our example is WSL2 virtual routes. As you now have OS inside your OS, they have a way of communicating. When you want to access your Linux OS’s network (for example, when you want to access your web app running locally in Linux), you can use _${PC-name}.local_. For me, since my PC name is Boris-PC, my network address is boris-pc.local. That way you don’t have to remember different IP addresses, which is really cool. If you want your address for whatever reason, you can go to your Linux distro’s terminal, and type ipconfig. Then, you can see your Windows IP and Linux’s IP address. With that, you can communicate with both operating systems without friction. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lkhcfiybnobuoziitwtm.png) + +The final cool thing I want to highlight is Linux GUI apps. It is a very cool feature that helps make WSL a more attractive proposal for regular users as well. You can install any app you want on your Linux system using popular package managers, such as apt (default on Ubuntu) or flatpak. Then you can launch them as well from the command line and the app will start and be visible inside your Windows OS. But that can cause some friction and is not user-friendly. The really ground-breaking part of this feature is that you can launch them directly from your Windows OS without even starting WSL yourself. Therefore, you can create shortcuts and pin them to the Start menu or taskbar without any friction and really have no need to think about where your app comes from. For the showcase, I have installed Dolphin File Manager and run it through Windows OS. You can see it action below side by side with Windows Explorer. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yq1nxj244jd1fci13oay.png) + +## Getting started with development on WSL + +After hearing all about the cool features of WSL, let’s slowly get back on track with our tutorial. Next up is setting up our dev environment and starting our first app. I’ll be setting up a web dev environment and we’ll use [Wasp](https://wasp-lang.dev/) as an example. + +If you aren’t familiar with it, Wasp is a Rails-like framework for React, Node.js, and Prisma. It’s a fast and easy way to develop and deploy your full-stack web apps. For our tutorial, Wasp is a perfect candidate, since it doesn’t support Windows development natively, but only through WSL as it requires a Unix environment. + +Let’s get started with installing Node.js first. At the moment, Wasp requires users to use the Node v18 (version requirement will be relaxed very soon), so we want to start with both Node.js and NVM installation. + +But first things first, let’s start with Node.js. In WSL, run: + +```jsx +sudo apt install nodejs +``` + +in order to install Node on your Linux environment. Next up is NVM. I suggest going to https://github.com/nvm-sh/nvm and getting the latest install script from there. The current download is: + +```bash +curl -o- [https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh](https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh) | bash +``` + +After this, we have both Node.js and NVM set up in our system. + +### Installing Wasp + +Next up is installing Wasp on our Linux environment. Wasp installation is also pretty straightforward and easy. So just copy and paste this command: + +```bash +curl -sSL [https://get.wasp-lang.dev/installer.sh](https://get.wasp-lang.dev/installer.sh) | sh +``` + +and wait for the installer to finish up its thing. Great! But, if you did your WSL setup from 0, you will notice the following warning underneath: It looks like '/home/boris/.local/bin' is not on your PATH! You will not be able to invoke wasp from the terminal by its name. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/em932e89tlzajv4rm6up.png) + +Let’s fix this quickly. In order to do this, let’s run + +```bash + code ~/.profile +``` + +If we don’t already have VS Code, it will automatically set up everything needed and boot up so you can add the command to the end of your file. It will be different for everyone depending on their system name. For example, mine is: + +```bash +export PATH=$PATH:/home/boris/.local/bin +``` + +Great! Now we just need to swap node version to v18.14.2 to ensure full compatibility with Wasp. We’ll install and switch to Node 18 in one go! To do this, simply run: + +```bash +nvm install v18.14.2 && nvm use v18.14.2 +``` + +### Setting up VS Code + +After setting up Wasp, we want to see how to run the app and access it from VS Code. Under the hood, you will still be using WSL for our development, but we’ll be able to use our VS Code from Host OS (Windows) for most of the things. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/orifa202sph4swgbir2d.png) + +To get started, download the [WSL extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) to your VS Code in Windows. Afterward, let’s start a new Wasp project to see how it works in action. Open your VS Code Command Palette (ctrl + shift + P) and select the option to “Open Folder in WSL”. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l1le8xvk6a8a8teog8eo.png) + +The folder that I have opened is + +```bash +\\wsl.localhost\Ubuntu\home\boris\Projects +``` + +That is the “Projects” folder inside my home folder in WSL. There are 2 ways for us to know that we are in WSL: The top bar and in the bottom left corner of VS Code. In both places, we have WSL: Ubuntu written, as is shown on screenshots. + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mzhu765415sravn3vypu.png) + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cpy4kggtsobod1vk1dqn.png) + +Once inside this folder, I will open a terminal. It will also be already connected to the proper folder in WSL, so we can get down to business! Let’s run the + +```bash +wasp new +``` + +command to create a new Wasp application. I have chosen the basic template, but you are free to create a project of your choosing, e.g. [SaaS starter](https://github.com/wasp-lang/SaaS-Template-GPT) with GPT, Stripe and more preconfigured. As shown in the screenshot, we should change the current directory of our project to the proper one and then run our project with it. + +```bash +wasp start +``` + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l453mcae56kfa3yrm7j4.png) + +And just like that, a new screen will open on my Windows machine, showcasing that my Wasp app is open. Cool! My address is still the default localhost:3000, but it is being run from the WSL. Congratulations, you’ve successfully started your first Wasp app through WSL. That wasn’t hard, was it? + +![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vfyfok2eg0xjhqcqhgoe.png) + +For our final topic, I want to highlight Git workflow with WSL, as it is relatively painless to set up. You can always do the manual git config setup, but I have something cooler for you: Sharing credentials between Windows and WSL. To set up sharing Git credentials, we have to do the following. In Powershell (on Windows), configure the credential manager on Windows. + +```bash +git config --global credential.helper wincred +``` + +And let’s do the same inside WSL. + +```bash +git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe" +``` + +This allows us to share our Git username and password. Anything set up in Windows will work in WSL (and vice-versa) and we can use Git inside WSL as we prefer (via VS Code GUI or via shell). + +## Conclusion + +Through our journey here, we have learned what WSL is, how it can be useful for enhancing our workflow with our Windows PC, but also how to set up your initial development environment on it. Microsoft has done a fantastic job with this tool and has really made Windows OS a much more approachable and viable option for all developers. We went through how to install the dev tools needed to kickstart development and how to get a handle on a basic dev workflow. Here are some important links if you want to dive deeper into the topic: + +- [https://github.com/microsoft/WSL](https://github.com/microsoft/WSL) +- [https://learn.microsoft.com/en-us/windows/wsl/install](https://learn.microsoft.com/en-us/windows/wsl/install) +- [https://code.visualstudio.com/docs/remote/wsl](https://code.visualstudio.com/docs/remote/wsl) diff --git a/web/blog/2023-12-05-writing-rfcs.md b/web/blog/2023-12-05-writing-rfcs.md new file mode 100644 index 0000000000..3a954076d4 --- /dev/null +++ b/web/blog/2023-12-05-writing-rfcs.md @@ -0,0 +1,190 @@ +--- +title: "On the Importance of RFCs in Programming" +authors: [matijasos] +image: /img/writing-rfcs/rfc-prophet.png +tags: [programming, clean-code] +--- + +import ImgWithCaption from './components/ImgWithCaption' + +Imagine you’ve been tasked to implement a sizeable new feature for the product you’re working on. That’s the opportunity you’ve been waiting for - everybody will see what a 10x developer you are! You open a list of the coolest new libraries and design patterns you’ve wanted to try out and get right into it, full “basement” mode. One week later, you victoriously emerge and present your perfect pull request! + +**But then, the senior dev in a team immediately rejects it - *“Too complex, you should have simply used library X and reused Y.”***. What!? Before you know it, you’re looking at 100 comments on your PR and days of refactoring to follow. + +If only there were **a way of knowing about X and Y before implementing everything**. Well, it is, and it’s called RFC! + + + + +We’ll learn about it through the example of [RFC about implementing an authentication system in a web framework Wasp](https://wasp-lang.notion.site/RFC-Auth-without-user-defined-entities-6d2925439627456ab01b74ff4b4cd087?pvs=4). [Wasp](https://github.com/wasp-lang/wasp) is a full-stack web framework built on top of React, Node.js and Prisma. It is used by [MAGE](https://usemage.ai/), a free GPT-powered codebase generator, which has been used to start over 30,000 applications. + +Let's dive in! + +## So, what is an RFC? + +RFC (*Request For Comments*) is, simply explained, a document proposing a codebase change to solve a specific problem. **Its main purpose is to find the best way to solve a problem, as a team effort, before the implementation starts**. RFCs were first adopted by the open-source community, but today, they are used in almost any type of developer organization. + + + +There are other names for this type of document you might encounter in the industry, like TDD (*Technical Design Document*) or SDD (*Software Design Document*). Some people argue over the distinction between them, but we won’t. + +**Fun fact**: RFCs were invented by IETF (*Internet Engineering Task Force*), the engineering organization behind some of the most important internet standards and protocols we use today, like TCP/IP! Not too shabby, right? + +## When should I write RFC, and when can I skip it? + + + +So, why bother writing about what you will eventually code, instead of saving time and simply doing it? **If you’re dealing with a bug or a relatively simple feature, where it’s very clear what you must do and doesn’t affect project structure, then there’s no need for an RFC - fire up that IDE and get cracking!** + +But, if you are introducing a completely new concept (e.g., introducing a role-based permission system) or altering the project’s architecture (e.g., adding support for running background jobs), then you might want to take a step back before typing `git checkout -b my-new-feature` and diving into that sweet coding zone. + +All the above being said, sometimes it's not easy to figure out if you should write an RFC or not. Maybe it’s a more prominent feature, but you’ve done something similar before, and you’ve already mapped everything out in your head and pretty much have no questions. To help with that, here’s a simple heuristic I like to use: **Is there more than one obvious way to implement this feature? Is there a new library/service we have to pick?** If the answer to both of these is “No", you probably don’t need an RFC. Otherwise, there’s a discussion to be had, and RFC is the way to do it. + + + +## It sounds useful. But what’s in it for me? + +We’ve established how to decide *when* to write an RFC, but here is also *why* you should do it: + +- **You will organize your thoughts and get clarity**. If you’ve decided to write an RFC, that means you’re dealing with a non-trivial, open-ended problem. Writing things down will help distill your thoughts and have an objective look at them. +- **You will learn more** than if you just jumped into coding. You will give yourself space to explore different approaches and oftentimes discover something you haven’t even thought of initially. +- **You will crowdsource your team’s knowledge.** By asking your team for feedback (hence Request For Comments), you will get a complete picture of the problem you’re solving and fill in any remaining gaps. +- **You will advance your team’s understanding of the codebase.** By collaborating on your RFC, everybody on the team will understand what you’re doing and how you eventually did it. That means next time somebody has to touch that part of the code, they will need to ask you much less questions (=== more uninterrupted coding time!). +- **PR reviews will go *much* smoother**. Remember that situation from the beginning of this article, when your PR got rejected as "too complex"? That’s because the reviewer is missing the context, and you made a sizeable change without a previous buy-in from the rest of the team. By writing an RFC first, you’ll never encounter this type of situation again. +- **Your documentation is already 50% done!** To be clear, RFC is not the final documentation, and you cannot simply point to it, but you can likely reuse a lot - images, diagrams, paragraphs, etc. + +Wow, this sounds so good that I want to come up with a new feature right now just so I can write an RFC for it! Joke aside, going through with the RFC first makes the coding part so much more enjoyable - you know exactly what you need to do, and you don’t need to question your approach and how it will be received once you create that PR. + +## Ok, ok, I’m sold! So, how do I go about writing one? + +Glad you asked! Many different formats are being used, more or less formal, but I prefer to keep it simple. RFCs that we write at Wasp don’t follow a strict format, but there are some common parts: + +- **Metadata** - Title, date, reviewers, etc… +- **Problem / Goal** +- **Proposed solution** (or more of them) +- **Implementation overview** +- **Remarks / open questions** + +That’s pretty much the gist of it! Each of these can be further broken down and refined, but this is the basic outline you can start with. + +Let’s now go over each of these and see what they look like in practice, on our [Authentication in Wasp](https://wasp-lang.notion.site/RFC-Auth-without-user-defined-entities-6d2925439627456ab01b74ff4b4cd087?pvs=4) example. + +## Metadata ⌗ + + + +This one is pretty self-explanatory - you will want to track some basic info about your RFCs - status, date of creation, etc. + +Some templates also explicitly list the reviewers and the status of their “approval” of the RFC, similar to the PR review process - we don’t have it since we’re a small team where communication happens fast, but it can be handy for larger teams where not everybody knows everybody, and you want to have a bit more of a process in place (e.g. when mentoring junior developers). + + + +## The problem 🤔 + +This is where things get interesting. **The better you define the problem or the goal/feature you need to implement, and why you need to do it, the easier all the following steps will be**. So this is something worth investing in even before you start writing your RFC - make sure you talk to all the involved parties (e.g., product owner, other developers, and even users) to refine your understanding of the issue you’re about to tackle. + +By doing this, you will also very likely get first hints and pointers on the possible solutions, and develop a rough sense of the problem space you’re in. + + + +Here are a few tips from the example above: + +- **Start with a high-level summary** - that way, readers can quickly decide if this is relevant to them or not and whether they should keep reading. +- **Provide some context** - Explain a bit about the current state of the world, as it is right now. This can be a single sentence or a whole chapter, depending on the intended audience. +- **Clearly state the problem/goal** - explain why there is a problem and connect it with the user’s/company’s pain, so that motivation is clear. +- **Provide extra details if possible** - diagrams, code examples, … → anything that can help the reader get faster to that “aha” moment. Extra points for using collapsible sections, so the central part of the RFC remains of digestible length. + +If you did all this, you’re already well on your way to the excellent RFC! Since defining the problem well is essential, don’t be afraid to add more to it and break things down further. + +### Non-goals 🛑 + +This is the sub-section of the "Problem" or "Goal" section that can sometimes be super valuable. Writing what we don't want or will not be doing in this codebase change can help set the expectations and better define its scope. + +For example, if we are working on adding a role-based authentication system to our app, people might assume that we will also build some sort of an admin panel for it to manage users and add/remove roles. By explicitly stating it won't be done (and briefly explaining why - not needed, it would take too long, it will be done in the next iteration, ...), reviewers will get a better understanding of what your goal is and you will skip unnecessary discussion. + +## Solution & Implementation 🛠️ + +Once we know what we want to do, we have to figure out the best way of doing it! You might have already hinted at the possible solution in the Problem section, but now is the moment to dive deeper - research different approaches, evaluate their pros and cons, and sketch how they could fit into the existing system. + +This section is probably the most free-form of all - since it highly depends on the nature of what you are doing, it doesn’t make sense to impose many restrictions here. You may want to stay at the higher level of, e.g., system architecture, or you may need to dive deep into the code and start writing parts of the code you will need. Due to that, I don’t have an exact format for you to follow, but rather a set of guidelines: + +### Write pseudocode + +The purpose of RFC is to convey ideas and principles, not production-grade code that compiles and covers all the edge cases. Feel free to invent/imagine/sketch whatever you need (e.g., imagine you already have a function that sends an email and just use it, even if you don’t), and don’t encumber yourself or the reader with the implementation details (unless that’s exactly what the RFC is about). + +It’s better to start at the higher level, and then go deeper when you realize you need it or if one of the reviewers suggests it. + +### Find out how are others doing it + + + +How you find this out may differ depending on the type of product you’re developing, but there is almost always a way to do it. If you’re developing an open-source tool like [Wasp](https://github.com/wasp-lang/wasp) you can simply check out other popular solutions (that are also open-source) and learn how they did it. If you’re working on a SaaS and need to figure out whether to use cookies or JWTs for the authentication, you likely have some friends who have done it before, and you can ask them. Lastly, simply Google/GPT it. + +Why is this so helpful? **The reason is that it gives you (and the reviewers) confidence in your solution. If somebody else did it successfully this way, it might be a promising direction.** It also might help you discover approaches you haven’t thought of before, or serve as a basis on top of which you can build. Of course, never take anything for granted and take into account the specific needs of your situation, but definitely make use of the knowledge and expertise of others. + +### Leave things unfinished & don't make it perfect + +The main point of RFC is the “C” part, so collaboration (yes, I know it actually stands for "_comments_"). **RFC is not a test where you have to get the perfect score and have no questions asked - if that happens, you probably shouldn’t have written it in the first place.** + +Solving a problem is a team effort, and you’re just the person taking the first stab at it and pushing things forward. Your task is to lay as much groundwork as you reasonably can (refine the problem, explore multiple approaches to solving it, identify new subproblems that came to light) so the reviewers can quickly grasp the status and provide efficient feedback, directed where it’s needed the most. + +**The main job of your RFC is to identify the most important problems and direct the reviewer’s attention to them, not solve them.** + +The RFC you’re writing should be looked at as a discussion area and a work-in-progress, not a piece of art that has to be perfected before it’s displayed in front of the audience. + +## Remarks & open questions 🎯 + +In this final section of the document, you can summarise the main thoughts and highlight the biggest open questions. After going through everything, it can be helpful for the reader to be reminded of where his attention can be most valuable. + +## Now I know when and how to write an RFC! Do you have any templates I could use as a starting point? + +Of course! As mentioned, our format is extremely lightweight, but feel free to take a look at [the RFC we used as an example](https://wasp-lang.notion.site/RFC-Auth-without-user-defined-entities-6d2925439627456ab01b74ff4b4cd087?pvs=4) to get inspired. Your company could also already have a ready template they recommend. + +Here are a few you can use and/or adapt to your needs: + +- [Squarespace RFC template](https://engineering.squarespace.com/s/Squarespace-RFC-Template.pdf) +- _Do you have a template you would recommend? I'm happy to list it here!_ + +## What tool should I use to write my RFCs? There are so many choices! + +The exact tool you’re using is probably the least important part of RFC-ing, but it still matters since it sets the workflow around it. If your company has already selected a tool, then of course stick with that. If not, here are the most common choices I’ve come across, along with quick comments: + +- **Google Docs** - the classic choice. Super easy to comment on any part of the doc, which is the most important feature. +- **Notion** - also great for collaboration, plus offers some markdown components such as collapsibles and tables, which can make your RFC more readable. +- **GitHub issues / PRs** - this is sometimes used, especially for OSS projects. The drawback is that it is harder to comment on the specific part of the document (you can only comment on the whole line), plus inserting diagrams is also quite clunky. The pro is that everything (code and RFCs) stays on the same platform + +We currently use Notion, but any of the above can be a good choice. + +## Summary + +Just as it is the best practice to write a summary at the end of your RFC, we will do the same here! This article came out longer than I expected, but there were so many things to mention - I hope you'll find it useful! + +Finally, **being able to clearly express your thoughts, formulate the problem, and objectively analyze the possible solutions, with feedback from the team, is what will help you develop the right thing, which is the ultimate productivity hack**. This is how you become a 10x engineer. + +And don't forget: *Weeks of coding can save you hours of planning.* \ No newline at end of file diff --git a/web/blog/authors.yml b/web/blog/authors.yml index 0064925f9f..e03e5c1642 100644 --- a/web/blog/authors.yml +++ b/web/blog/authors.yml @@ -3,12 +3,14 @@ martinsos: title: Co-founder & CTO @ Wasp url: https://github.com/martinsos image_url: https://github.com/martinsos.png + email: martin@wasp-lang.dev matijasos: name: Matija Sosic title: Co-founder & CEO @ Wasp url: https://github.com/matijasos image_url: https://github.com/matijasos.png + email: matija@wasp-lang.dev shayneczyzewski: name: Shayne Czyzewski @@ -21,6 +23,7 @@ sodic: title: Founding Engineer @ Wasp url: https://github.com/sodic image_url: https://github.com/sodic.png + email: filip@wasp-lang.dev maksym36ua: name: Maksym Khamrovskyi @@ -37,9 +40,17 @@ vinny: title: DevRel @ Wasp url: https://vincanger.github.io image_url: https://vincanger.github.io/assets/vince_smiley.jpg + email: vince@wasp-lang.dev miho: name: Mihovil Ilakovac title: Founding Engineer @ Wasp url: https://ilakovac.com image_url: https://github.com/infomiho.png + email: miho@wasp-lang.dev + +martinovicdev: + name: Boris Martinović + title: Contributor @ Wasp + url: https://martinovic.dev + image_url: https://github.com/martinovicdev.png diff --git a/web/docs/OldDocsNote.tsx b/web/docs/OldDocsNote.tsx deleted file mode 100644 index 7274803746..0000000000 --- a/web/docs/OldDocsNote.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Admonition from '@theme/Admonition' -import Link from '@docusaurus/Link' -import React from 'react' - -export default function OldDocsNote() { - return ( -
    - - This page is part of a previous documentation version and is no longer - actively maintained. The content is likely out of date and may no longer - be relevant to current releases. -
    -
    - Go to the current documentation for updated - content. -
    -
    - ) -} diff --git a/web/docs/advanced/apis.md b/web/docs/advanced/apis.md index 125499e348..767fce0100 100644 --- a/web/docs/advanced/apis.md +++ b/web/docs/advanced/apis.md @@ -3,9 +3,9 @@ title: Custom HTTP API Endpoints --- import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' -import { Required } from '@site/src/components/Required' +import { Required } from '@site/src/components/Tag' -In Wasp, the default client-server interaction mechanism is through [Operations](/docs/data-model/operations/overview). However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an `api`. Best of all, they should look and feel very familiar. +In Wasp, the default client-server interaction mechanism is through [Operations](../data-model/operations/overview). However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an `api`. Best of all, they should look and feel very familiar. ## How to Create an API @@ -231,11 +231,11 @@ export const apiMiddleware: MiddlewareConfigFn = (config) => { We are returning the default middleware which enables CORS for all APIs under the `/foo` path. -For more information about middleware configuration, please see: [Middleware Configuration](/docs/advanced/middleware-config) +For more information about middleware configuration, please see: [Middleware Configuration](../advanced/middleware-config) ## Using Entities in APIs -In many cases, resources used in APIs will be [Entities](/docs/data-model/entities.md). +In many cases, resources used in APIs will be [Entities](../data-model/entities.md). To use an Entity in your API, add it to the `api` declaration in Wasp: @@ -340,4 +340,4 @@ The `api` declaration has the following fields: - `middlewareConfigFn: ServerImport` - The import statement to an Express middleware config function for this API. See more in [middleware section](/docs/advanced/middleware-config) of the docs. \ No newline at end of file + The import statement to an Express middleware config function for this API. See more in [middleware section](../advanced/middleware-config) of the docs. \ No newline at end of file diff --git a/web/docs/advanced/deployment/_addExternalAuthEnvVarsReminder.md b/web/docs/advanced/deployment/_addExternalAuthEnvVarsReminder.md index 54385d1413..29c532d974 100644 --- a/web/docs/advanced/deployment/_addExternalAuthEnvVarsReminder.md +++ b/web/docs/advanced/deployment/_addExternalAuthEnvVarsReminder.md @@ -1,4 +1,3 @@ :::tip Using an external auth method? - -If your app is using an external authentication method(s) supported by Wasp (such as [Google](/docs/auth/social-auth/google#4-adding-environment-variables) or [GitHub](/docs/auth/social-auth/github#4-adding-environment-variables)), make sure to additionally set the necessary environment variables specifically required by these method(s). +If your app is using an external authentication method(s) supported by Wasp (such as [Google](../../auth/social-auth/google#4-adding-environment-variables) or [GitHub](../../auth/social-auth/github#4-adding-environment-variables)), make sure to additionally set the necessary environment variables specifically required by these method(s). ::: diff --git a/web/docs/advanced/deployment/cli.md b/web/docs/advanced/deployment/cli.md index 2dd3378f9c..03187804ed 100644 --- a/web/docs/advanced/deployment/cli.md +++ b/web/docs/advanced/deployment/cli.md @@ -2,7 +2,7 @@ title: Deploying with the Wasp CLI --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; Wasp CLI can deploy your full-stack application with only a single command. The command automates the manual deployment process and is the recommended way of deploying Wasp apps. diff --git a/web/docs/advanced/deployment/manually.md b/web/docs/advanced/deployment/manually.md index a4b4f2146d..198ffc2b26 100644 --- a/web/docs/advanced/deployment/manually.md +++ b/web/docs/advanced/deployment/manually.md @@ -5,7 +5,7 @@ title: Deploying Manually import useBaseUrl from '@docusaurus/useBaseUrl'; import AddExternalAuthEnvVarsReminder from './\_addExternalAuthEnvVarsReminder.md' import BuildingTheWebClient from './\_building-the-web-client.md' -import { Required } from '@site/src/components/Required' +import { Required } from '@site/src/components/Tag' We'll cover how to deploy your Wasp app manually to a variety of providers: @@ -35,7 +35,7 @@ wasp build :::caution PostgreSQL in production You won't be able to build the app if you are using SQLite as a database (which is the default database). -You'll have to [switch to PostgreSQL](/docs/data-model/backends#migrating-from-sqlite-to-postgresql) before deploying to production. +You'll have to [switch to PostgreSQL](../../data-model/backends#migrating-from-sqlite-to-postgresql) before deploying to production. ::: ### 2. Deploying the API Server (backend) @@ -98,7 +98,7 @@ We'll cover a few different deployment providers below: We will show how to deploy the server and provision a database for it on Fly.io. :::tip We automated this process for you -If you want to do all of the work below with one command, you can use the [Wasp CLI](/docs/advanced/deployment/cli#flyio). +If you want to do all of the work below with one command, you can use the [Wasp CLI](../../advanced/deployment/cli#flyio). Wasp CLI deploys the server, deploys the client, and sets up a database. It also gives you a way to redeploy (update) your app with a single command. @@ -559,7 +559,7 @@ heroku logs --tail --app :::note Using `pg-boss` with Heroku -If you wish to deploy an app leveraging [Jobs](/docs/advanced/jobs) that use `pg-boss` as the executor to Heroku, you need to set an additional environment variable called `PG_BOSS_NEW_OPTIONS` to `{"connectionString":"","ssl":{"rejectUnauthorized":false}}`. This is because pg-boss uses the `pg` extension, which does not seem to connect to Heroku over SSL by default, which Heroku requires. Additionally, Heroku uses a self-signed cert, so we must handle that as well. +If you wish to deploy an app leveraging [Jobs](../../advanced/jobs) that use `pg-boss` as the executor to Heroku, you need to set an additional environment variable called `PG_BOSS_NEW_OPTIONS` to `{"connectionString":"","ssl":{"rejectUnauthorized":false}}`. This is because pg-boss uses the `pg` extension, which does not seem to connect to Heroku over SSL by default, which Heroku requires. Additionally, Heroku uses a self-signed cert, so we must handle that as well. Read more: https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js ::: diff --git a/web/docs/advanced/deployment/overview.md b/web/docs/advanced/deployment/overview.md index 23f841bbf9..c4750284e2 100644 --- a/web/docs/advanced/deployment/overview.md +++ b/web/docs/advanced/deployment/overview.md @@ -11,7 +11,7 @@ Wasp apps are full-stack apps that consist of: You can deploy each part **anywhere** where you can usually deploy Node.js apps or static apps. For example, you can deploy your client on [Netlify](https://www.netlify.com/), the server on [Fly.io](https://fly.io/), and the database on [Neon](https://neon.tech/). -To make deploying as smooth as possible, Wasp also offers a single-command deployment through the **Wasp CLI**. Read more about deploying through the CLI [here](/docs/advanced/deployment/cli). +To make deploying as smooth as possible, Wasp also offers a single-command deployment through the **Wasp CLI**. Read more about deploying through the CLI [here](../../advanced/deployment/cli). diff --git a/web/docs/advanced/email/_dummy-provider-note.md b/web/docs/advanced/email/_dummy-provider-note.md new file mode 100644 index 0000000000..861476e6e9 --- /dev/null +++ b/web/docs/advanced/email/_dummy-provider-note.md @@ -0,0 +1,4 @@ +:::note Dummy Provider is not for production use + +The `Dummy` provider is not for production use. It is only meant to be used during development. If you try building your app with the `Dummy` provider, the build will fail. +::: \ No newline at end of file diff --git a/web/docs/advanced/email/email.md b/web/docs/advanced/email/email.md new file mode 100644 index 0000000000..009f1848dd --- /dev/null +++ b/web/docs/advanced/email/email.md @@ -0,0 +1,401 @@ +--- +title: Sending Emails +--- + +import { Required } from '@site/src/components/Tag' +import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' +import DummyProviderNote from './_dummy-provider-note.md' + +# Sending Emails + +With Wasp's email-sending feature, you can easily integrate email functionality into your web application. + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: , + defaultFrom: { + name: "Example", + email: "hello@itsme.com" + }, + } +} +``` + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: , + defaultFrom: { + name: "Example", + email: "hello@itsme.com" + }, + } +} +``` + + + + +Choose from one of the providers: + +- `Dummy` (development only), +- `Mailgun`, +- `SendGrid` +- or the good old `SMTP`. + +Optionally, define the `defaultFrom` field, so you don't need to provide it whenever sending an email. + +## Sending Emails + +Before jumping into details about setting up various providers, let's see how easy it is to send emails. + +You import the `emailSender` that is provided by the `@wasp/email/index.js` module and call the `send` method on it. + + + + +```js title="src/actions/sendEmail.js" +import { emailSender } from "@wasp/email/index.js"; + +// In some action handler... +const info = await emailSender.send({ + from: { + name: "John Doe", + email: "john@doe.com", + }, + to: "user@domain.com", + subject: "Saying hello", + text: "Hello world", + html: "Hello world", +}); +``` + + + + +```ts title="src/actions/sendEmail.ts" +import { emailSender } from "@wasp/email/index.js"; + +// In some action handler... +const info = await emailSender.send({ + from: { + name: "John Doe", + email: "john@doe.com", + }, + to: "user@domain.com", + subject: "Saying hello", + text: "Hello world", + html: "Hello world", +}); +``` + + + + +Read more about the `send` method in the [API Reference](#javascript-api). + +The `send` method returns an object with the status of the sent email. It varies depending on the provider you use. + +## Providers + +We'll go over all of the available providers in the next section. For some of them, you'll need to set up some env variables. You can do that in the `.env.server` file. + +### Using the Dummy Provider + + + +To speed up development, Wasp offers a `Dummy` email sender that `console.log`s the emails in the console. Since it doesn't send emails for real, it doesn't require any setup. + +Set the provider to `Dummy` in your `main.wasp` file. + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: Dummy, + } +} +``` + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: Dummy, + } +} +``` + + + + +### Using the SMTP Provider + +First, set the provider to `SMTP` in your `main.wasp` file. + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: SMTP, + } +} +``` + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: SMTP, + } +} +``` + + + + +Then, add the following env variables to your `.env.server` file. + +```properties title=".env.server" +SMTP_HOST= +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_PORT= +``` + +Many transactional email providers (e.g. Mailgun, SendGrid but also others) can also use SMTP, so you can use them as well. + +### Using the Mailgun Provider + +Set the provider to `Mailgun` in the `main.wasp` file. + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: Mailgun, + } +} +``` + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: Mailgun, + } +} +``` + + + + +Then, get the Mailgun API key and domain and add them to your `.env.server` file. + +#### Getting the API Key and Domain + +1. Go to [Mailgun](https://www.mailgun.com/) and create an account. +2. Go to [API Keys](https://app.mailgun.com/app/account/security/api_keys) and create a new API key. +3. Copy the API key and add it to your `.env.server` file. +4. Go to [Domains](https://app.mailgun.com/app/domains) and create a new domain. +5. Copy the domain and add it to your `.env.server` file. + +```properties title=".env.server" +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +``` + +### Using the SendGrid Provider + +Set the provider field to `SendGrid` in your `main.wasp` file. + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: SendGrid, + } +} +``` + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: SendGrid, + } +} +``` + + + + +Then, get the SendGrid API key and add it to your `.env.server` file. + +#### Getting the API Key + +1. Go to [SendGrid](https://sendgrid.com/) and create an account. +2. Go to [API Keys](https://app.sendgrid.com/settings/api_keys) and create a new API key. +3. Copy the API key and add it to your `.env.server` file. + +```properties title=".env.server" +SENDGRID_API_KEY= +``` + +## API Reference + +### `emailSender` dict + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: , + defaultFrom: { + name: "Example", + email: "hello@itsme.com" + }, + } +} +``` + + + + +```wasp title="main.wasp" +app Example { + ... + emailSender: { + provider: , + defaultFrom: { + name: "Example", + email: "hello@itsme.com" + }, + } +} +``` + + + + +The `emailSender` dict has the following fields: + +- `provider: Provider` + + The provider you want to use. Choose from `Dummy`, `SMTP`, `Mailgun` or `SendGrid`. + + + +- `defaultFrom: dict` + + The default sender's details. If you set this field, you don't need to provide the `from` field when sending an email. + +### JavaScript API + +Using the `emailSender` in TypescriptJavaScript: + + + +```js title="src/actions/sendEmail.js" +import { emailSender } from "@wasp/email/index.js"; + +// In some action handler... +const info = await emailSender.send({ + from: { + name: "John Doe", + email: "john@doe.com", + }, + to: "user@domain.com", + subject: "Saying hello", + text: "Hello world", + html: "Hello world", +}); +``` + + + + +```ts title="src/actions/sendEmail.ts" +import { emailSender } from "@wasp/email/index.js"; + +// In some action handler... +const info = await emailSender.send({ + from: { + name: "John Doe", + email: "john@doe.com", + }, + to: "user@domain.com", + subject: "Saying hello", + text: "Hello world", + html: "Hello world", +}); +``` + + + + +The `send` method accepts an object with the following fields: + +- `from: object` + + The sender's details. If you set up `defaultFrom` field in the `emailSender` dict in Wasp file, this field is optional. + + - `name: string` + + The name of the sender. + + - `email: string` + + The email address of the sender. + +- `to: string` + + The recipient's email address. + +- `subject: string` + + The subject of the email. + +- `text: string` + + The text version of the email. + +- `html: string` + + The HTML version of the email diff --git a/web/docs/advanced/jobs.md b/web/docs/advanced/jobs.md index 3fe783a145..df59da9c33 100644 --- a/web/docs/advanced/jobs.md +++ b/web/docs/advanced/jobs.md @@ -2,7 +2,7 @@ title: Recurring Jobs --- -import { Required } from '@site/src/components/Required' +import { Required } from '@site/src/components/Tag' import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' In most web apps, users send requests to the server and receive responses with some data. When the server responds quickly, the app feels responsive and smooth. @@ -94,7 +94,7 @@ Let's write an example Job that will print a message to the console and return a `MySpecialJob` is a generic type Wasp generates to help you correctly type the Job's worker function, ensuring type information about the function's arguments and return value. Read more about type-safe jobs in the [Javascript API section](#javascript-api). -3. After successfully defining the job, you can submit work to be done in your [Operations](/docs/data-model/operations/overview) or [setupFn](/docs/project/server-config#setup-function) (or any other NodeJS code): +3. After successfully defining the job, you can submit work to be done in your [Operations](../data-model/operations/overview) or [setupFn](../project/server-config#setup-function) (or any other NodeJS code): @@ -333,7 +333,7 @@ The Job declaration has the following fields: - `entities: [Entity]` - A list of entities you wish to use inside your Job (similar to [Queries and Actions](/docs/data-model/operations/queries#using-entities-in-queries)). + A list of entities you wish to use inside your Job (similar to [Queries and Actions](../data-model/operations/queries#using-entities-in-queries)). ### JavaScript API diff --git a/web/docs/advanced/links.md b/web/docs/advanced/links.md index eb19fece93..f7510db0d7 100644 --- a/web/docs/advanced/links.md +++ b/web/docs/advanced/links.md @@ -2,7 +2,7 @@ title: Type-Safe Links --- -import { Required } from '@site/src/components/Required' +import { Required } from '@site/src/components/Tag' If you are using Typescript, you can use Wasp's custom `Link` component to create type-safe links to other pages on your site. diff --git a/web/docs/advanced/middleware-config.md b/web/docs/advanced/middleware-config.md index fe2ce9f0bc..859df3d6de 100644 --- a/web/docs/advanced/middleware-config.md +++ b/web/docs/advanced/middleware-config.md @@ -19,7 +19,7 @@ Wasp's Express server has the following middleware by default: - [express.json](https://expressjs.com/en/api.html#express.json) (which uses [body-parser](https://github.com/expressjs/body-parser#bodyparserjsonoptions)): parses incoming request bodies in a middleware before your handlers, making the result available under the `req.body` property. :::note - JSON middlware is required for [Operations](/docs/data-model/operations/overview) to function properly. + JSON middlware is required for [Operations](../data-model/operations/overview) to function properly. ::: - [express.urlencoded](https://expressjs.com/en/api.html#express.urlencoded) (which uses [body-parser](https://expressjs.com/en/resources/middleware/body-parser.html#bodyparserurlencodedoptions)): returns middleware that only parses urlencoded bodies and only looks at requests where the `Content-Type` header matches the type option. - [cookieParser](https://github.com/expressjs/cookie-parser#readme): parses Cookie header and populates `req.cookies` with an object keyed by the cookie names. diff --git a/web/docs/advanced/web-sockets.md b/web/docs/advanced/web-sockets.md index ac3f3b6053..de4032a488 100644 --- a/web/docs/advanced/web-sockets.md +++ b/web/docs/advanced/web-sockets.md @@ -3,7 +3,7 @@ title: Web Sockets --- import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs } from '@site/src/components/TsJsHelpers'; -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; Wasp provides a fully integrated WebSocket experience by utilizing [Socket.IO](https://socket.io/) on the client and server. diff --git a/web/docs/auth/_multiple-identities-warning.md b/web/docs/auth/_multiple-identities-warning.md new file mode 100644 index 0000000000..ea8f5d8251 --- /dev/null +++ b/web/docs/auth/_multiple-identities-warning.md @@ -0,0 +1,6 @@ +:::caution Using multiple auth identities for a single user + +Wasp currently doesn't support multiple auth identities for a single user. This means, for example, that a user can't have both an email-based auth identity and a Google-based auth identity. This is something we will add in the future with the introduction of the [account merging feature](https://github.com/wasp-lang/wasp/issues/954). + +Account merging means that multiple auth identities can be merged into a single user account. For example, a user's email and Google identity can be merged into a single user account. Then the user can log in with either their email or Google account and they will be logged into the same account. +::: diff --git a/web/docs/auth/_read-more-about-auth-entities.md b/web/docs/auth/_read-more-about-auth-entities.md new file mode 100644 index 0000000000..bafd959cac --- /dev/null +++ b/web/docs/auth/_read-more-about-auth-entities.md @@ -0,0 +1 @@ +You can read more about how the `User` entity is connected to the rest of the auth system in the [Auth Entities](./entities) section of the docs. \ No newline at end of file diff --git a/web/docs/auth/_user-fields.md b/web/docs/auth/_user-fields.md new file mode 100644 index 0000000000..32319805d7 --- /dev/null +++ b/web/docs/auth/_user-fields.md @@ -0,0 +1,8 @@ +import { Required } from '@site/src/components/Tag'; + +The user entity needs to have the following fields: +- `id` + + It can be of any type, but it needs to be marked with `@id` + +You can add any other fields you want to the user entity. Make sure to also define them in the `userSignupFields` field if they need to be set during the sign-up process. \ No newline at end of file diff --git a/web/docs/auth/_user-signup-fields-explainer.md b/web/docs/auth/_user-signup-fields-explainer.md new file mode 100644 index 0000000000..bde9c14b4c --- /dev/null +++ b/web/docs/auth/_user-signup-fields-explainer.md @@ -0,0 +1,40 @@ +`userSignupFields` defines all the extra fields that need to be set on the `User` during the sign-up process. For example, if you have `address` and `phone` fields on your `User` entity, you can set them by defining the `userSignupFields` like this: + + + + +```ts title="server/auth.js" +import { defineUserSignupFields } from '@wasp/auth/index.js' + +export const userSignupFields = defineUserSignupFields({ + address: (data) => { + if (!data.address) { + throw new Error('Address is required') + } + return data.address + } + phone: (data) => data.phone, +}) +``` + + + + +```ts title="server/auth.ts" +import { defineUserSignupFields } from '@wasp/auth/index.js' + +export const userSignupFields = defineUserSignupFields({ + address: (data) => { + if (!data.address) { + throw new Error('Address is required') + } + return data.address + } + phone: (data) => data.phone, +}) +``` + + + + +Read more about the `userSignupFields` function [here](../auth/overview#1-defining-extra-fields). diff --git a/web/docs/auth/email.md b/web/docs/auth/email.md index 0916966d76..21473fc80b 100644 --- a/web/docs/auth/email.md +++ b/web/docs/auth/email.md @@ -2,26 +2,25 @@ title: Email --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; +import MultipleIdentitiesWarning from './\_multiple-identities-warning.md'; +import ReadMoreAboutAuthEntities from './\_read-more-about-auth-entities.md'; +import GetEmail from './entities/\_get-email.md'; +import UserSignupFieldsExplainer from './\_user-signup-fields-explainer.md'; +import UserFields from './\_user-fields.md'; Wasp supports e-mail authentication out of the box, along with email verification and "forgot your password?" flows. It provides you with the server-side implementation and email templates for all of these flows. ![Auth UI](/img/authui/all_screens.gif) -:::caution Using email auth and social auth together -If a user signs up with Google or Github (and you set it up to save their social provider e-mail info on the `User` entity), they'll be able to reset their password and login with e-mail and password ✅ - -If a user signs up with the e-mail and password and then tries to login with a social provider (Google or Github), they won't be able to do that ❌ - -In the future, we will lift this limitation and enable smarter merging of accounts. -::: + ## Setting Up Email Authentication We'll need to take the following steps to set up email authentication: 1. Enable email authentication in the Wasp file -1. Add the user entity -1. Add the routes and pages +1. Add the `User` entity +1. Add the auth routes and pages 1. Use Auth UI components in our pages 1. Set up the email sender @@ -73,7 +72,6 @@ app myApp { passwordReset: { clientRoute: PasswordResetRoute, }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/login", @@ -108,7 +106,6 @@ app myApp { passwordReset: { clientRoute: PasswordResetRoute, }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/login", @@ -123,20 +120,16 @@ Read more about the `email` auth method options [here](#fields-in-the-email-dict ### 2. Add the User Entity -When email authentication is enabled, Wasp expects certain fields in your `userEntity`. Let's add these fields to our `main.wasp` file: +The `User` entity can be as simple as including only the `id` field: -```wasp title="main.wasp" {4-8} +```wasp title="main.wasp" // 5. Define the user entity entity User {=psl + // highlight-next-line id Int @id @default(autoincrement()) - email String? @unique - password String? - isEmailVerified Boolean @default(false) - emailVerificationSentAt DateTime? - passwordResetSentAt DateTime? // Add your own fields below // ... psl=} @@ -144,15 +137,11 @@ psl=} -```wasp title="main.wasp" {4-8} +```wasp title="main.wasp" // 5. Define the user entity entity User {=psl + // highlight-next-line id Int @id @default(autoincrement()) - email String? @unique - password String? - isEmailVerified Boolean @default(false) - emailVerificationSentAt DateTime? - passwordResetSentAt DateTime? // Add your own fields below // ... psl=} @@ -160,7 +149,8 @@ psl=} -Read more about the `userEntity` fields [here](#userentity-fields). + + ### 3. Add the Routes and Pages @@ -240,7 +230,7 @@ We'll define the React components for these pages in the `client/pages/auth.{jsx ### 4. Create the Client Pages :::info -We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](/docs/project/css-frameworks). +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../project/css-frameworks). ::: Let's create a `auth.{jsx,tsx}` file in the `client/pages` folder and add the following to it: @@ -418,15 +408,15 @@ export function Layout({ children }: { children: React.ReactNode }) { -We imported the generated Auth UI components and used them in our pages. Read more about the Auth UI components [here](/docs/auth/ui). +We imported the generated Auth UI components and used them in our pages. Read more about the Auth UI components [here](../auth/ui). ### 5. Set up an Email Sender To support e-mail verification and password reset flows, we need an e-mail sender. Luckily, Wasp supports several email providers out of the box. -We'll use SendGrid in this guide to send our e-mails. You can use any of the supported email providers. +We'll use the `Dummy` provider to speed up the setup. It just logs the emails to the console instead of sending them. You can use any of the [supported email providers](../advanced/email#providers). -To set up SendGrid to send emails, we will add the following to our `main.wasp` file: +To set up the `Dummy` provider to send emails, add the following to the `main.wasp` file: @@ -436,7 +426,7 @@ app myApp { // ... // 7. Set up the email sender emailSender: { - provider: SendGrid, + provider: Dummy, } } ``` @@ -448,28 +438,18 @@ app myApp { // ... // 7. Set up the email sender emailSender: { - provider: SendGrid, + provider: Dummy, } } ``` -... and add the following to our `.env.server` file: - -```c title=".env.server" -SENDGRID_API_KEY= -``` - -If you are not sure how to get a SendGrid API key, read more [here](/docs/advanced/email#getting-the-api-key). - -Read more about setting up email senders in the [sending emails docs](/docs/advanced/email). - ### Conclusion That's it! We have set up email authentication in our app. 🎉 -Running `wasp db migrate-dev` and then `wasp start` should give you a working app with email authentication. If you want to put some of the pages behind authentication, read the [using auth docs](/docs/auth/overview). +Running `wasp db migrate-dev` and then `wasp start` should give you a working app with email authentication. If you want to put some of the pages behind authentication, read the [auth overview](../auth/overview). ## Login and Signup Flows @@ -477,10 +457,6 @@ Running `wasp db migrate-dev` and then `wasp start` should give you a working ap ![Auth UI](/img/authui/login.png) -If logging in with an unverified email is _allowed_, the user will be able to login with an unverified email address. If logging in with an unverified email is _not allowed_, the user will be shown an error message. - -Read more about the `allowUnverifiedLogin` option [here](#allowunverifiedlogin-bool-specifies-whether-the-user-can-login-without-verifying-their-e-mail-address). - ### Signup ![Auth UI](/img/authui/signup.png) @@ -500,10 +476,21 @@ Some of the behavior you get out of the box: 4. Password validation - Read more about the default password validation rules and how to override them in [using auth docs](/docs/auth/overview). + Read more about the default password validation rules and how to override them in [auth overview docs](../auth/overview). ## Email Verification Flow +:::info Automatic email verification in development + +In development mode, you can skip the email verification step by setting the `SKIP_EMAIL_VERIFICATION_IN_DEV` environment variable to `true` in your `.env.server` file: + +```env title=".env.server" +SKIP_EMAIL_VERIFICATION_IN_DEV=true +``` + +This is useful when you are developing your app and don't want to go through the email verification flow every time you sign up. It can be also useful when you are writing automated tests for your app. +::: + By default, Wasp requires the e-mail to be verified before allowing the user to log in. This is done by sending a verification email to the user's email address and requiring the user to click on a link in the email to verify their email address. Our setup looks like this: @@ -595,9 +582,249 @@ Users can enter their new password there. The content of the e-mail can be customized, read more about it [here](#passwordreset-passwordresetconfig-). -## Using The Auth +## Creating a Custom Sign-up Action + +:::caution Creating a custom sign-up action + +We don't recommend creating a custom sign-up action unless you have a good reason to do so. It is a complex process and you can easily make a mistake that will compromise the security of your app. +::: -To read more about how to set up the logout button and how to get access to the logged-in user in our client and server code, read the [using auth docs](/docs/auth/overview). +The code of your custom sign-up action can look like this: + + + + +```wasp title="main.wasp" +// ... + +action customSignup { + fn: import { signup } from "@server/auth/signup.js", +} +``` + +```js title="src/server/auth/signup.js" +import { + ensurePasswordIsPresent, + ensureValidPassword, + ensureValidEmail, +} from '@wasp/auth/validation.js' +import { + createProviderId, + sanitizeAndSerializeProviderData, + deserializeAndSanitizeProviderData, + findAuthIdentity, + createUser, +} from '@wasp/auth/utils.js' +import { + createEmailVerificationLink, + sendEmailVerificationEmail, +} from '@wasp/auth/providers/email/utils.js' + +export const signup = async (args, _context) => { + ensureValidEmail(args) + ensurePasswordIsPresent(args) + ensureValidPassword(args) + + try { + const providerId = createProviderId('email', args.email) + const existingAuthIdentity = await findAuthIdentity(providerId) + + if (existingAuthIdentity) { + const providerData = deserializeAndSanitizeProviderData(existingAuthIdentity.providerData) + // Your custom code here + } else { + // sanitizeAndSerializeProviderData will hash the user's password + const newUserProviderData = await sanitizeAndSerializeProviderData({ + hashedPassword: args.password, + isEmailVerified: false, + emailVerificationSentAt: null, + passwordResetSentAt: null, + }) + await createUser( + providerId, + providerData, + // Any additional data you want to store on the User entity + {}, + ) + + // Verification link links to a client route e.g. /email-verification + const verificationLink = await createEmailVerificationLink(args.email, '/email-verification'); + try { + await sendEmailVerificationEmail( + args.email, + { + from: { + name: "My App Postman", + email: "hello@itsme.com", + }, + to: args.email, + subject: "Verify your email", + text: `Click the link below to verify your email: ${verificationLink}`, + html: ` +

    Click the link below to verify your email

    + Verify email + `, + } + ); + } catch (e: unknown) { + console.error("Failed to send email verification email:", e); + throw new HttpError(500, "Failed to send email verification email."); + } + } + } catch (e) { + return { + success: false, + message: e.message, + } + } + + // Your custom code after sign-up. + // ... + + return { + success: true, + message: 'User created successfully', + } +} +``` +
    + + +```wasp title="main.wasp" +// ... + +action customSignup { + fn: import { signup } from "@server/auth/signup.js", +} +``` + +```ts title="src/server/auth/signup.ts" +import { + ensurePasswordIsPresent, + ensureValidPassword, + ensureValidEmail, +} from '@wasp/auth/validation.js' +import { + createProviderId, + sanitizeAndSerializeProviderData, + deserializeAndSanitizeProviderData, + findAuthIdentity, + createUser, +} from '@wasp/auth/utils.js' +import { + createEmailVerificationLink, + sendEmailVerificationEmail, +} from '@wasp/auth/providers/email/utils.js' +import type { CustomSignup } from '@wasp/actions/types' + +type CustomSignupInput = { + email: string + password: string +} +type CustomSignupOutput = { + success: boolean + message: string +} + +export const signup: CustomSignup = async (args, _context) => { + ensureValidEmail(args) + ensurePasswordIsPresent(args) + ensureValidPassword(args) + + try { + const providerId = createProviderId('email', args.email) + const existingAuthIdentity = await findAuthIdentity(providerId) + + if (existingAuthIdentity) { + const providerData = deserializeAndSanitizeProviderData<'email'>(existingAuthIdentity.providerData) + // Your custom code here + } else { + // sanitizeAndSerializeProviderData will hash the user's password + const newUserProviderData = await sanitizeAndSerializeProviderData<'email'>({ + hashedPassword: args.password, + isEmailVerified: false, + emailVerificationSentAt: null, + passwordResetSentAt: null, + }) + await createUser( + providerId, + providerData, + // Any additional data you want to store on the User entity + {}, + ) + + // Verification link links to a client route e.g. /email-verification + const verificationLink = await createEmailVerificationLink(args.email, '/email-verification'); + try { + await sendEmailVerificationEmail( + args.email, + { + from: { + name: "My App Postman", + email: "hello@itsme.com", + }, + to: args.email, + subject: "Verify your email", + text: `Click the link below to verify your email: ${verificationLink}`, + html: ` +

    Click the link below to verify your email

    + Verify email + `, + } + ); + } catch (e: unknown) { + console.error("Failed to send email verification email:", e); + throw new HttpError(500, "Failed to send email verification email."); + } + } + } catch (e) { + return { + success: false, + message: e.message, + } + } + + // Your custom code after sign-up. + // ... + + return { + success: true, + message: 'User created successfully', + } +} +``` +
    +
    + +We suggest using the built-in field validators for your authentication flow. You can import them from `@wasp/auth/validation.js`. These are the same validators that Wasp uses internally for the default authentication flow. + +#### Email + +- `ensureValidEmail(args)` + + Checks if the email is valid and throws an error if it's not. Read more about the validation rules [here](../auth/overview#default-validations). + +#### Password + +- `ensurePasswordIsPresent(args)` + + Checks if the password is present and throws an error if it's not. + +- `ensureValidPassword(args)` + + Checks if the password is valid and throws an error if it's not. Read more about the validation rules [here](../auth/overview#default-validations). + +## Using Auth + +To read more about how to set up the logout button and how to get access to the logged-in user in our client and server code, read the [auth overview docs](../auth/overview). + +### `getEmail` + +If you are looking to access the user's email in your code, you can do that by accessing the info about the user that is stored in the `user.auth.identities` array. + +To make things a bit easier for you, Wasp offers the `getEmail` helper. + + ## API Reference @@ -608,7 +835,7 @@ Let's go over the options we can specify when using email authentication. -```wasp title="main.wasp" {18-25} +```wasp title="main.wasp" app myApp { title: "My app", // ... @@ -625,20 +852,16 @@ app myApp { // ... } -// Using email auth requires the `userEntity` to have at least the following fields entity User {=psl + // highlight-next-line id Int @id @default(autoincrement()) - email String? @unique - password String? - isEmailVerified Boolean @default(false) - emailVerificationSentAt DateTime? - passwordResetSentAt DateTime? psl=} ``` + -```wasp title="main.wasp" {18-25} +```wasp title="main.wasp" app myApp { title: "My app", // ... @@ -655,26 +878,15 @@ app myApp { // ... } -// Using email auth requires the `userEntity` to have at least the following fields entity User {=psl + // highlight-next-line id Int @id @default(autoincrement()) - email String? @unique - password String? - isEmailVerified Boolean @default(false) - emailVerificationSentAt DateTime? - passwordResetSentAt DateTime? psl=} ``` -Email auth requires that `userEntity` specified in `auth` contains: - -- optional `email` field of type `String` -- optional `password` field of type `String` -- `isEmailVerified` field of type `Boolean` with a default value of `false` -- optional `emailVerificationSentAt` field of type `DateTime` -- optional `passwordResetSentAt` field of type `DateTime` + ### Fields in the `email` dict @@ -690,6 +902,7 @@ app myApp { userEntity: User, methods: { email: { + userSignupFields: import { userSignupFields } from "@server/auth.js", fromField: { name: "My App", email: "hello@itsme.com" @@ -702,7 +915,6 @@ app myApp { clientRoute: PasswordResetRoute, getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/someRoute" @@ -722,6 +934,7 @@ app myApp { userEntity: User, methods: { email: { + userSignupFields: import { userSignupFields } from "@server/auth.js", fromField: { name: "My App", email: "hello@itsme.com" @@ -734,7 +947,6 @@ app myApp { clientRoute: PasswordResetRoute, getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", }, - allowUnverifiedLogin: false, }, }, onAuthFailedRedirectTo: "/someRoute" @@ -745,6 +957,10 @@ app myApp {
    +#### `userSignupFields: ServerImport` + + + #### `fromField: EmailFromField` `fromField` is a dict that specifies the name and e-mail address of the sender of the e-mails sent by your app. @@ -904,11 +1120,3 @@ It has the following fields: This is the default content of the e-mail, you can customize it to your liking. - -#### `allowUnverifiedLogin: bool`: specifies whether the user can login without verifying their e-mail address - -It defaults to `false`. If `allowUnverifiedLogin` is set to `true`, the user can login without verifying their e-mail address, otherwise users will receive a `401` error when trying to login without verifying their e-mail address. - -Sometimes you want to allow unverified users to login to provide them a different onboarding experience. Some of the pages can be viewed without verifying the e-mail address, but some of them can't. You can use the `isEmailVerified` field on the user entity to check if the user has verified their e-mail address. - -If you have any questions, feel free to ask them on [our Discord server](https://discord.gg/rzdnErX). \ No newline at end of file diff --git a/web/docs/auth/entities/_get-email.md b/web/docs/auth/entities/_get-email.md new file mode 100644 index 0000000000..02d09bcde5 --- /dev/null +++ b/web/docs/auth/entities/_get-email.md @@ -0,0 +1,48 @@ +The `getEmail` helper returns the user's email or `null` if the user doesn't have an email auth identity. + + + + +```jsx title="src/client/MainPage.jsx" +import { getEmail } from '@wasp/auth/user' + +const MainPage = ({ user }) => { + const email = getEmail(user) + // ... +} +``` + +```js title=src/server/tasks.js +import { getEmail } from '@wasp/auth/user.js' + +export const createTask = async (args, context) => { + const email = getEmail(context.user) + // ... +} +``` + + + + + +```tsx title="src/client/MainPage.tsx" +import { getEmail } from '@wasp/auth/user' +import { User as AuthenticatedUser } from '@wasp/auth/types' + +const MainPage = ({ user }: { user: AuthenticatedUser }) => { + const email = getEmail(user) + // ... +} +``` + +```ts title=src/server/tasks.ts +import { getEmail } from '@wasp/auth/user.js' + +export const createTask: CreateTask<...> = async (args, context) => { + const email = getEmail(context.user) + // ... +} +``` + + + diff --git a/web/docs/auth/entities/_get-username.md b/web/docs/auth/entities/_get-username.md new file mode 100644 index 0000000000..c7667b2c0a --- /dev/null +++ b/web/docs/auth/entities/_get-username.md @@ -0,0 +1,48 @@ +The `getUsername` helper returns the user's username or `null` if the user doesn't have a username auth identity. + + + + +```jsx title="src/client/MainPage.jsx" +import { getUsername } from '@wasp/auth/user' + +const MainPage = ({ user }) => { + const username = getUsername(user) + // ... +} +``` + +```js title=src/server/tasks.js +import { getUsername } from '@wasp/auth/user.js' + +export const createTask = async (args, context) => { + const username = getUsername(context.user) + // ... +} +``` + + + + + +```tsx title="src/client/MainPage.tsx" +import { getUsername } from '@wasp/auth/user' +import { User as AuthenticatedUser } from '@wasp/auth/types' + +const MainPage = ({ user }: { user: AuthenticatedUser }) => { + const username = getUsername(user) + // ... +} +``` + +```ts title=src/server/tasks.ts +import { getUsername } from '@wasp/auth/user.js' + +export const createTask: CreateTask<...> = async (args, context) => { + const username = getUsername(context.user) + // ... +} +``` + + + \ No newline at end of file diff --git a/web/docs/auth/entities/entities.md b/web/docs/auth/entities/entities.md new file mode 100644 index 0000000000..2dd6e3820d --- /dev/null +++ b/web/docs/auth/entities/entities.md @@ -0,0 +1,450 @@ +--- +title: Auth Entities +--- + +import ImgWithCaption from '@site/blog/components/ImgWithCaption' +import { Internal } from '@site/src/components/Tag' +import MultipleIdentitiesWarning from '../\_multiple-identities-warning.md'; +import GetEmail from './\_get-email.md'; +import GetUsername from './\_get-username.md'; + +Wasp supports multiple different authentication methods and for each method, we need to store different information about the user. For example, if you are using the [Username & password](./username-and-pass) authentication method, we need to store the user's username and password. On the other hand, if you are using the [Email](./email) authentication method, you will need to store the user's email, password and for example, their email verification status. + +## Entities Explained + +To store user information, Wasp creates a few entities behind the scenes. In this section, we will explain what entities are created and how they are connected. + +### User Entity + +When you want to add authentication to your app, you need to specify the user entity e.g. `User` in your Wasp file. This entity is a "business logic user" which represents a user of your app. + +You can use this entity to store any information about the user that you want to store. For example, you might want to store the user's name or address. You can also use the user entity to define the relations between users and other entities in your app. For example, you might want to define a relation between a user and the tasks that they have created. + +```wasp +entity User {=psl + id Int @id @default(autoincrement()) + // Any other fields you want to store about the user +psl=} +``` + +You **own** the user entity and you can modify it as you wish. You can add new fields to it, remove fields from it, or change the type of the fields. You can also add new relations to it or remove existing relations from it. + + + +On the other hand, the `Auth`, `AuthIdentity` and `Session` entities are created behind the scenes and are used to store the user's login credentials. You as the developer don't need to care about this entity most of the time. Wasp **owns** these entities. + +In the case you want to create a custom signup action, you will need to use the `Auth` and `AuthIdentity` entities directly. + +### Example App Model +Let's imagine we created a simple tasks management app: + + - The app has email and Google-based auth. + - Users can create tasks and see the tasks that they have created. + +Let's look at how would that look in the database: + + + +If we take a look at an example user in the database, we can see: +- The business logic user, `User` is connected to multiple `Task` entities. + - In this example, "Example User" has two tasks. +- The `User` is connected to exactly one `Auth` entity. +- Each `Auth` entity can have multiple `AuthIdentity` entities. + - In this example, the `Auth` entity has two `AuthIdentity` entities: one for the email-based auth and one for the Google-based auth. +- Each `Auth` entity can have multiple `Session` entities. + - In this example, the `Auth` entity has one `Session` entity. + + + +### `Auth` Entity + +Wasp's internal `Auth` entity is used to connect the business logic user, `User` with the user's login credentials. + +```wasp +entity Auth {=psl + id String @id @default(uuid()) + userId Int? @unique + // Wasp injects this relation on the User entity as well + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + identities AuthIdentity[] + sessions Session[] +psl=} +``` + +The `Auth` fields: + +- `id` is a unique identifier of the `Auth` entity. +- `userId` is a foreign key to the `User` entity. + - It is used to connect the `Auth` entity with the business logic user. +- `user` is a relation to the `User` entity. + - This relation is injected on the `User` entity as well. +- `identities` is a relation to the `AuthIdentity` entity. +- `sessions` is a relation to the `Session` entity. + +### `AuthIdentity` Entity + +The `AuthIdentity` entity is used to store the user's login credentials for various authentication methods. + +```wasp +entity AuthIdentity {=psl + providerName String + providerUserId String + providerData String @default("{}") + authId String + auth Auth @relation(fields: [authId], references: [id], onDelete: Cascade) + + @@id([providerName, providerUserId]) +psl=} +``` + +The `AuthIdentity` fields: +- `providerName` is the name of the authentication provider. + - For example, `email` or `google`. +- `providerUserId` is the user's ID in the authentication provider. + - For example, the user's email or Google ID. +- `providerData` is a JSON string that contains additional data about the user from the authentication provider. + - For example, for password based auth, this field contains the user's hashed password. + - This field is a `String` and not a `Json` type because [Prisma doesn't support the `Json` type for SQLite](https://github.com/prisma/prisma/issues/3786). +- `authId` is a foreign key to the `Auth` entity. + - It is used to connect the `AuthIdentity` entity with the `Auth` entity. +- `auth` is a relation to the `Auth` entity. + +### `Session` Entity + +The `Session` entity is used to store the user's session information. It is used to keep the user logged in between page refreshes. + +```wasp +entity Session {=psl + id String @id @unique + expiresAt DateTime + userId String + auth Auth @relation(references: [id], fields: [userId], onDelete: Cascade) + + @@index([userId]) +psl=} +``` + +The `Session` fields: +- `id` is a unique identifier of the `Session` entity. +- `expiresAt` is the date when the session expires. +- `userId` is a foreign key to the `Auth` entity. + - It is used to connect the `Session` entity with the `Auth` entity. +- `auth` is a relation to the `Auth` entity. + +## Accessing the Auth Fields + +If you are looking to access the user's email or username in your code, you can do that by accessing the info about the user that is stored in the `AuthIdentity` entity. + +Everywhere where Wasp gives you the `user` object, it also includes the `auth` relation with the `identities` relation. This means that you can access the auth identity info by using the `user.auth.identities` array. + +To make things a bit easier for you, Wasp offers a few helper functions that you can use to access the auth identity info. + +### `getEmail` + + + +### `getUsername` + + + +### `getFirstProviderUserId` + +The `getFirstProviderUserId` helper returns the first user ID (e.g. `username` or `email`) that it finds for the user or `null` if it doesn't find any. + +[As mentioned before](#authidentity-entity-), the `providerUserId` field is how providers identify our users. For example, the user's `username` in the case of the username auth or the user's `email` in the case of the email auth. This can be useful if you support multiple authentication methods and you need *any* ID that identifies the user in your app. + + + + +```jsx title="src/client/MainPage.jsx" +import { getFirstProviderUserId } from '@wasp/auth/user' + +const MainPage = ({ user }) => { + const userId = getFirstProviderUserId(user) + // ... +} +``` + +```js title=src/server/tasks.js +import { getFirstProviderUserId } from '@wasp/auth/user.js' + +export const createTask = async (args, context) => { + const userId = getFirstProviderUserId(context.user) + // ... +} +``` + + + + + +```tsx title="src/client/MainPage.tsx" +import { getFirstProviderUserId } from '@wasp/auth/user' +import { User as AuthenticatedUser } from '@wasp/auth/types' + +const MainPage = ({ user }: { user: AuthenticatedUser }) => { + const userId = getFirstProviderUserId(user) + // ... +} +``` + +```ts title=src/server/tasks.ts +import { getFirstProviderUserId } from '@wasp/auth/user.js' + +export const createTask: CreateTask<...> = async (args, context) => { + const userId = getFirstProviderUserId(context.user) + // ... +} +``` + + + + +### `findUserIdentity` + +You can find a specific auth identity by using the `findUserIdentity` helper function. This function takes a `user` and a `providerName` and returns the first `providerName` identity that it finds or `null` if it doesn't find any. + +Possible provider names are: +- `email` +- `username` +- `google` +- `github` + +This can be useful if you want to check if the user has a specific auth identity. For example, you might want to check if the user has an email auth identity or Google auth identity. + + + + +```jsx title="src/client/MainPage.jsx" +import { findUserIdentity } from '@wasp/auth/user' + +const MainPage = ({ user }) => { + const emailIdentity = findUserIdentity(user, 'email') + const googleIdentity = findUserIdentity(user, 'google') + if (emailIdentity) { + // ... + } else if (googleIdentity) { + // ... + } + // ... +} +``` + +```js title=src/server/tasks.js +import { findUserIdentity } from '@wasp/auth/user.js' + +export const createTask = async (args, context) => { + const emailIdentity = findUserIdentity(context.user, 'email') + const googleIdentity = findUserIdentity(context.user, 'google') + if (emailIdentity) { + // ... + } else if (googleIdentity) { + // ... + } + // ... +} +``` + + + + + +```tsx title="src/client/MainPage.tsx" +import { findUserIdentity } from '@wasp/auth/user' +import { User as AuthenticatedUser } from '@wasp/auth/types' + +const MainPage = ({ user }: { user: AuthenticatedUser }) => { + const emailIdentity = findUserIdentity(user, 'email') + const googleIdentity = findUserIdentity(user, 'google') + if (emailIdentity) { + // ... + } else if (googleIdentity) { + // ... + } + // ... +} +``` + +```ts title=src/server/tasks.ts +import { findUserIdentity } from '@wasp/auth/user.js' + +export const createTask: CreateTask<...> = async (args, context) => { + const emailIdentity = findUserIdentity(context.user, 'email') + const googleIdentity = findUserIdentity(context.user, 'google') + if (emailIdentity) { + // ... + } else if (googleIdentity) { + // ... + } + // ... +} +``` + + + + +## Custom Signup Action + +Let's take a look at how you can use the `Auth` and `AuthIdentity` entities to create custom login and signup actions. For example, you might want to create a custom signup action that creates a user in your app and also creates a user in a third-party service. + +:::info Custom Signup Examples + +In the [Email](./email#creating-a-custom-sign-up-action) section of the docs we give you an example for custom email signup and in the [Username & password](./username-and-pass#2-creating-your-custom-sign-up-action) section of the docs we give you an example for custom username & password signup. +::: + +Below is a simplified version of a custom signup action which you probably wouldn't use in your app but it shows you how you can use the `Auth` and `AuthIdentity` entities to create a custom signup action. + + + + +```wasp title="main.wasp" +// ... + +action customSignup { + fn: import { signup } from "@server/auth/signup.js", + entities: [User] +} +``` + + +```js title="src/server/auth/signup.js" +import { + createProviderId, + sanitizeAndSerializeProviderData, + createUser, +} from '@wasp/auth/utils.js' + +export const signup = async (args, { entities: { User } }) => { + try { + // Provider ID is a combination of the provider name and the provider user ID + // And it is used to uniquely identify the user in your app + const providerId = createProviderId('username', args.username) + // sanitizeAndSerializeProviderData hashes the password and returns a JSON string + const providerData = await sanitizeAndSerializeProviderData({ + hashedPassword: args.password, + }) + + await createUser( + providerId, + providerData, + // Any additional data you want to store on the User entity + {}, + ) + + // This is equivalent to: + // await User.create({ + // data: { + // auth: { + // create: { + // identities: { + // create: { + // providerName: 'username', + // providerUserId: args.username + // providerData, + // }, + // }, + // } + // }, + // } + // }) + } catch (e) { + return { + success: false, + message: e.message, + } + } + + // Your custom code after sign-up. + // ... + + return { + success: true, + message: 'User created successfully', + } +} +``` + + + +```wasp title="main.wasp" +// ... + +action customSignup { + fn: import { signup } from "@server/auth/signup.js", + entities: [User] +} +``` + +```ts title="src/server/auth/signup.ts" +import { + createProviderId, + sanitizeAndSerializeProviderData, + createUser, +} from '@wasp/auth/utils.js' +import type { CustomSignup } from '@wasp/actions/types' + +type CustomSignupInput = { + username: string + password: string +} +type CustomSignupOutput = { + success: boolean + message: string +} + +export const signup: CustomSignup< + CustomSignupInput, + CustomSignupOutput +> = async (args, { entities: { User } }) => { + try { + // Provider ID is a combination of the provider name and the provider user ID + // And it is used to uniquely identify the user in your app + const providerId = createProviderId('username', args.username) + // sanitizeAndSerializeProviderData hashes the password and returns a JSON string + const providerData = await sanitizeAndSerializeProviderData<'username'>({ + hashedPassword: args.password, + }) + + await createUser( + providerId, + providerData, + // Any additional data you want to store on the User entity + {}, + ) + + // This is equivalent to: + // await User.create({ + // data: { + // auth: { + // create: { + // identities: { + // create: { + // providerName: 'username', + // providerUserId: args.username + // providerData, + // }, + // }, + // } + // }, + // } + // }) + } catch (e) { + return { + success: false, + message: e.message, + } + } + + // Your custom code after sign-up. + // ... + + return { + success: true, + message: 'User created successfully', + } +} +``` + + + +You can use whichever method suits your needs better: either the `createUser` function or Prisma's `User.create` method. The `createUser` function is a bit more convenient to use because it hides some of the complexity. On the other hand, the `User.create` method gives you more control over the data that is stored in the `Auth` and `AuthIdentity` entities. \ No newline at end of file diff --git a/web/docs/auth/overview.md b/web/docs/auth/overview.md index a4df59b54b..f3071de837 100644 --- a/web/docs/auth/overview.md +++ b/web/docs/auth/overview.md @@ -1,13 +1,20 @@ --- -title: Using Auth +title: Overview --- import { AuthMethodsGrid } from "@site/src/components/AuthMethodsGrid"; -import { Required } from "@site/src/components/Required"; +import { Required } from '@site/src/components/Tag'; +import ReadMoreAboutAuthEntities from './\_read-more-about-auth-entities.md'; -Auth is an essential piece of any serious application. Coincidentally, Wasp provides authentication and authorization support out of the box 🙃. +Auth is an essential piece of any serious application. That's why Wasp provides authentication and authorization support out of the box. -Enabling auth for your app is optional and can be done by configuring the `auth` field of the `app` declaration. +Here's a 1-minute tour of how full-stack auth works in Wasp: + +
    + +
    + +Enabling auth for your app is optional and can be done by configuring the `auth` field of your `app` declaration: @@ -18,7 +25,6 @@ app MyApp { //... auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { usernameAndPassword: {}, // use this or email, not both email: {}, // use this or usernameAndPassword, not both @@ -41,7 +47,6 @@ app MyApp { //... auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { usernameAndPassword: {}, // use this or email, not both email: {}, // use this or usernameAndPassword, not both @@ -72,11 +77,11 @@ Wasp supports the following auth methods: -Let's say we enabled the [Username & password](/docs/auth/username-and-pass) authentication. +Let's say we enabled the [Username & password](../auth/username-and-pass) authentication. -We get an auth backend with signup and login endpoints. We also get the `user` object in our [Operations](/docs/data-model/operations/overview) and we can decide what to do based on whether the user is logged in or not. +We get an auth backend with signup and login endpoints. We also get the `user` object in our [Operations](../data-model/operations/overview) and we can decide what to do based on whether the user is logged in or not. -We would also get the [Auth UI](/docs/auth/ui) generated for us. We can set up our login and signup pages where our users can **create their account** and **login**. We can then protect certain pages by setting `authRequired: true` for them. This will make sure that only logged-in users can access them. +We would also get the [Auth UI](../auth/ui) generated for us. We can set up our login and signup pages where our users can **create their account** and **login**. We can then protect certain pages by setting `authRequired: true` for them. This will make sure that only logged-in users can access them. We will also have access to the `user` object in our frontend code, so we can show different UI to logged-in and logged-out users. For example, we can show the user's name in the header alongside a **logout button** or a login button if the user is not logged in. @@ -146,7 +151,33 @@ const LogoutButton = () => { ## Accessing the logged-in user -You can get access to the `user` object both in the backend and on the frontend. +You can get access to the `user` object both on the server and on the client. The `user` object contains the logged-in user's data. + +The `user` object has all the fields that you defined in your `User` entity, plus the `auth` field which contains the auth identities connected to the user. For example, if the user signed up with their email, the `user` object might look something like this: + +```js +const user = { + id: "19c7d164-b5cb-4dde-a0cc-0daea77cf854", + + // Your entity's fields. + address: "My address", + // ... + + // Auth identities connected to the user. + auth: { + id: "26ab6f96-ed76-4ee5-9ac3-2fd0bf19711f", + identities: [ + { + providerName: "email", + providerUserId: "some@email.com", + providerData: { ... }, + }, + ] + }, +} +``` + + ### On the client @@ -200,11 +231,11 @@ page AccountPage { ``` ```tsx title="client/pages/Account.tsx" -import type { User } from '@wasp/entities' +import { User as AuthenticatedUser } from '@wasp/auth/types' import Button from './Button' import logout from '@wasp/auth/logout' -const AccountPage = ({ user }: { user: User }) => { +const AccountPage = ({ user }: { user: AuthenticatedUser }) => { return (
    @@ -295,7 +326,7 @@ Since the `user` prop is only available in a page's React component: use the `us #### Using the `context.user` object -When authentication is enabled, all [queries and actions](/docs/data-model/operations/overview) have access to the `user` object through the `context` argument. `context.user` contains all User entity's fields, except for the password. +When authentication is enabled, all [queries and actions](../data-model/operations/overview) have access to the `user` object through the `context` argument. `context.user` contains all User entity's fields and the auth identities connected to the user. We strip out the `hashedPassword` field from the identities for security reasons. @@ -355,25 +386,51 @@ export const createTask: CreateTask = async ( To implement access control in your app, each operation must check `context.user` and decide what to do. For example, if `context.user` is `undefined` inside a private operation, the user's access should be denied. -When using WebSockets, the `user` object is also available on the `socket.data` object. Read more in the [WebSockets section](/docs/advanced/web-sockets#websocketfn-function). +When using WebSockets, the `user` object is also available on the `socket.data` object. Read more in the [WebSockets section](../advanced/web-sockets#websocketfn-function). + +## Sessions + +Wasp's auth uses sessions to keep track of the logged-in user. The session is stored in `localStorage` on the client and in the database on the server. Under the hood, Wasp uses the excellent [Lucia Auth v3](https://v3.lucia-auth.com/) library for session management. + +When users log in, Wasp creates a session for them and stores it in the database. The session is then sent to the client and stored in `localStorage`. When users log out, Wasp deletes the session from the database and from `localStorage`. + +## User Entity -## User entity +### Password Hashing -### Password hashing +If you are saving a user's password in the database, you should **never** save it as plain text. You can use Wasp's helper functions for serializing and deserializing provider data which will automatically hash the password for you: -You don't need to worry about hashing the password yourself. Even when directly using the Prisma client and calling `create()` with a plain-text password, Wasp's middleware makes sure to hash the password before storing it in the database. -For example, if you need to update a user's password, you can safely use the Prisma client to do so, e.g., inside an Action: +```wasp title="main.wasp" +// ... + +action updatePassword { + fn: import { updatePassword } from "@server/auth.js", +} +``` ```js title="src/server/actions.js" +import { + createProviderId, + findAuthIdentity, + updateAuthIdentityProviderData, + deserializeAndSanitizeProviderData, +} from '@wasp/auth/utils.js'; + export const updatePassword = async (args, context) => { - return context.entities.User.update({ - where: { id: args.userId }, - data: { - password: 'New pwd which will be hashed automatically!', - }, + const providerId = createProviderId('email', args.email) + const authIdentity = await findAuthIdentity(providerId) + if (!authIdentity) { + throw new HttpError(400, "Unknown user") + } + + const providerData = deserializeAndSanitizeProviderData(authIdentity.providerData) + + // Updates the password and hashes it automatically. + await updateAuthIdentityProviderData(providerId, providerData, { + hashedPassword: args.password, }) } ``` @@ -382,22 +439,29 @@ export const updatePassword = async (args, context) => { ```ts title="src/server/actions.ts" +import { + createProviderId, + findAuthIdentity, + updateAuthIdentityProviderData, + deserializeAndSanitizeProviderData, +} from '@wasp/auth/utils.js'; import type { UpdatePassword } from '@wasp/actions/types' -import type { User } from '@wasp/entities' - -type UpdatePasswordPayload = { - userId: User['id'] -} export const updatePassword: UpdatePassword< - UpdatePasswordPayload, - User + { email: string; password: string }, + void, > = async (args, context) => { - return context.entities.User.update({ - where: { id: args.userId }, - data: { - password: 'New pwd which will be hashed automatically!', - }, + const providerId = createProviderId('email', args.email) + const authIdentity = await findAuthIdentity(providerId) + if (!authIdentity) { + throw new HttpError(400, "Unknown user") + } + + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData) + + // Updates the password and hashes it automatically. + await updateAuthIdentityProviderData(providerId, providerData, { + hashedPassword: args.password, }) } ``` @@ -405,26 +469,26 @@ export const updatePassword: UpdatePassword< -### Default validations +### Default Validations -When you are using the default authentication flow, Wasp validates the fields with some default validations. These validations run if you use Wasp's built-in [Auth UI](/docs/auth/ui) or if you use the provided auth actions. +When you are using the default authentication flow, Wasp validates the fields with some default validations. These validations run if you use Wasp's built-in [Auth UI](./ui) or if you use the provided auth actions. -If you decide to create your [custom auth actions](/docs/auth/username-and-pass#2-creating-your-custom-sign-up-action), you'll need to run the validations yourself. +If you decide to create your [custom auth actions](./username-and-pass#2-creating-your-custom-sign-up-action), you'll need to run the validations yourself. Default validations depend on the auth method you use. -#### Username & password +#### Username & Password -If you use [Username & password](/docs/auth/username-and-pass) authentication, the default validations are: +If you use [Username & password](./username-and-pass) authentication, the default validations are: - The `username` must not be empty - The `password` must not be empty, have at least 8 characters, and contain a number -Note that `username`s are stored in a **case-sensitive** manner. +Note that `username`s are stored in a **case-insensitive** manner. #### Email -If you use [Email](/docs/auth/email) authentication, the default validations are: +If you use [Email](./email) authentication, the default validations are: - The `email` must not be empty and a valid email address - The `password` must not be empty, have at least 8 characters, and contain a number @@ -471,12 +535,13 @@ try { ## Customizing the Signup Process -Sometimes you want to include **extra fields** in your signup process, like first name and last name. +Sometimes you want to include **extra fields** in your signup process, like first name and last name and save them in the `User` entity. -In Wasp, in this case: +For this to happen: - you need to define the fields that you want saved in the database, -- you need to customize the `SignupForm`. +- you need to customize the `SignupForm` (in the case of [Email](./email) or [Username & Password](./username-and-pass) auth) + Other times, you might need to just add some **extra UI** elements to the form, like a checkbox for terms of service. In this case, customizing only the UI components is enough. @@ -493,40 +558,39 @@ We do that by defining an object where the keys represent the field name, and th \* We exclude the `password` field from this object to prevent it from being saved as plain-text in the database. The `password` field is handled by Wasp's auth backend. -First, we add the `auth.signup.additionalFields` field in our `main.wasp` file: +First, we add the `auth.methods.{authMethod}.userSignupFields` field in our `main.wasp` file. The `{authMethod}` depends on the auth method you are using. + +For example, if you are using [Username & Password](./username-and-pass), you would add the `auth.methods.usernameAndPassword.userSignupFields` field: -```wasp title="main.wasp" {9-11} +```wasp title="main.wasp" app crudTesting { // ... auth: { userEntity: User, methods: { - usernameAndPassword: {}, + usernameAndPassword: { + userSignupFields: import { userSignupFields } from "@server/auth/signup.js", + }, }, onAuthFailedRedirectTo: "/login", - signup: { - additionalFields: import { fields } from "@server/auth/signup.js", - }, }, } entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String address String? psl=} ``` -Then we'll define and export the `fields` object from the `server/auth/signup.js` file: +Then we'll define the `userSignupFields` object in the `server/auth/signup.js` file: ```ts title="server/auth/signup.js" -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: async (data) => { const address = data.address if (typeof address !== 'string') { @@ -543,35 +607,32 @@ export const fields = defineAdditionalSignupFields({ -```wasp title="main.wasp" {9-11} +```wasp title="main.wasp" app crudTesting { // ... auth: { userEntity: User, methods: { - usernameAndPassword: {}, + usernameAndPassword: { + userSignupFields: import { userSignupFields } from "@server/auth/signup.js", + }, }, onAuthFailedRedirectTo: "/login", - signup: { - additionalFields: import { fields } from "@server/auth/signup.js", - }, }, } entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String address String? psl=} ``` -Then we'll export the `fields` object from the `server/auth/signup.ts` file: +Then we'll define the `userSignupFields` object in the `server/auth/signup.js` file: ```ts title="server/auth/signup.ts" -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: async (data) => { const address = data.address if (typeof address !== 'string') { @@ -590,7 +651,7 @@ export const fields = defineAdditionalSignupFields({ -Read more about the `fields` object in the [API Reference](#signup-fields-customization). +Read more about the `userSignupFields` object in the [API Reference](#signup-fields-customization). Keep in mind, that these field names need to exist on the `userEntity` you defined in your `main.wasp` file e.g. `address` needs to be a field on the `User` entity. @@ -608,10 +669,10 @@ You can use any validation library you want to validate the fields. For example, ```js title="server/auth/signup.js" -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' import * as z from 'zod' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: (data) => { const AddressSchema = z .string({ @@ -632,10 +693,10 @@ export const fields = defineAdditionalSignupFields({ ```ts title="server/auth/signup.ts" -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' import * as z from 'zod' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: (data) => { const AddressSchema = z .string({ @@ -663,7 +724,7 @@ Now that we defined the fields, Wasp knows how to: 1. Validate the data sent from the client 2. Save the data to the database -Next, let's see how to customize [Auth UI](/docs/auth/ui) to include those fields. +Next, let's see how to customize [Auth UI](../auth/ui) to include those fields. ### 2. Customizing the Signup Component @@ -673,8 +734,8 @@ If you are not using Wasp's Auth UI, you can skip this section. Just make sure t Read more about using the signup actions for: -- email auth [here](/docs/auth/email#fields-in-the-email-dict) -- username & password auth [here](/docs/auth/username-and-pass#customizing-the-auth-flow) +- email auth [here](../auth/email#fields-in-the-email-dict) +- username & password auth [here](../auth/username-and-pass#customizing-the-auth-flow) ::: If you are using Wasp's Auth UI, you can customize the `SignupForm` component by passing the `additionalFields` prop to it. It can be either a list of extra fields or a render function. @@ -870,7 +931,6 @@ Read more about the render function in the [API Reference](#signupform-customiza //... auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { usernameAndPassword: {}, // use this or email, not both email: {}, // use this or usernameAndPassword, not both @@ -894,7 +954,6 @@ app MyApp { //... auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { usernameAndPassword: {}, // use this or email, not both email: {}, // use this or usernameAndPassword, not both @@ -916,74 +975,9 @@ app MyApp { #### `userEntity: entity` -The entity representing the user. Its mandatory fields depend on your chosen auth method. +The entity representing the user connected to your business logic. -#### `externalAuthEntity: entity` - -Wasp requires you to set the field `auth.externalAuthEntity` for all authentication methods relying on an external authorizatino provider (e.g., Google). You also need to tweak the Entity referenced by `auth.userEntity`, as shown below. - - - - -```wasp {4,14} title="main.wasp" -//... - auth: { - userEntity: User, - externalAuthEntity: SocialLogin, -//... - -entity User {=psl - id Int @id @default(autoincrement()) - //... - externalAuthAssociations SocialLogin[] -psl=} - -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=} -``` - - - - -```wasp {4,14} title="main.wasp" -//... - auth: { - userEntity: User, - externalAuthEntity: SocialLogin, -//... - -entity User {=psl - id Int @id @default(autoincrement()) - //... - externalAuthAssociations SocialLogin[] -psl=} - -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=} -``` - - - - -:::note -The same `externalAuthEntity` can be used across different social login providers (e.g., both GitHub and Google can use the same entity). -::: - -See [Google docs](/docs/auth/social-auth/google) and [GitHub docs](/docs/auth/social-auth/github) for more details. + #### `methods: dict` @@ -994,7 +988,7 @@ A dictionary of auth methods enabled for the app. #### `onAuthFailedRedirectTo: String` The route to which Wasp should redirect unauthenticated user when they try to access a private page (i.e., a page that has `authRequired: true`). -Check out these [essentials docs on auth](/docs/tutorial/auth#adding-auth-to-the-project) to see an example of usage. +Check out these [essentials docs on auth](../tutorial/auth#adding-auth-to-the-project) to see an example of usage. #### `onAuthSucceededRedirectTo: String` @@ -1002,7 +996,7 @@ The route to which Wasp will send a successfully authenticated after a successfu The default value is `"/"`. :::note -Automatic redirect on successful login only works when using the Wasp-provided [Auth UI](/docs/auth/ui). +Automatic redirect on successful login only works when using the Wasp-provided [Auth UI](../auth/ui). ::: #### `signup: SignupOptions` @@ -1011,33 +1005,33 @@ Read more about the signup process customization API in the [Signup Fields Custo ### Signup Fields Customization -If you want to add extra fields to the signup process, the server needs to know how to save them to the database. You do that by defining the `auth.signup.additionalFields` field in your `main.wasp` file. +If you want to add extra fields to the signup process, the server needs to know how to save them to the database. You do that by defining the `auth.methods.{authMethod}.userSignupFields` field in your `main.wasp` file. -```wasp title="main.wasp" {9-11} +```wasp title="main.wasp" app crudTesting { // ... auth: { userEntity: User, methods: { - usernameAndPassword: {}, + usernameAndPassword: { + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/signup.js", + }, }, onAuthFailedRedirectTo: "/login", - signup: { - additionalFields: import { fields } from "@server/auth/signup.js", - }, }, } ``` -Then we'll export the `fields` object from the `server/auth/signup.js` file: +Then we'll export the `userSignupFields` object from the `server/auth/signup.js` file: ```ts title="server/auth/signup.js" -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: async (data) => { const address = data.address if (typeof address !== 'string') { @@ -1054,28 +1048,28 @@ export const fields = defineAdditionalSignupFields({ -```wasp title="main.wasp" {9-11} +```wasp title="main.wasp" app crudTesting { // ... auth: { userEntity: User, methods: { - usernameAndPassword: {}, + usernameAndPassword: { + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/signup.js", + }, }, onAuthFailedRedirectTo: "/login", - signup: { - additionalFields: import { fields } from "@server/auth/signup.js", - }, }, } ``` -Then we'll export the `fields` object from the `server/auth/signup.ts` file: +Then we'll export the `userSignupFields` object from the `server/auth/signup.ts` file: ```ts title="server/auth/signup.ts" -import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const fields = defineAdditionalSignupFields({ +export const userSignupFields = defineUserSignupFields({ address: async (data) => { const address = data.address if (typeof address !== 'string') { @@ -1092,13 +1086,13 @@ export const fields = defineAdditionalSignupFields({ -The `fields` object is an object where the keys represent the field name, and the values are functions which receive the data sent from the client\* and return the value of the field. +The `userSignupFields` object is an object where the keys represent the field name, and the values are functions that receive the data sent from the client\* and return the value of the field. -If the field value is invalid, the function should throw an error. +If the value that the function received is invalid, the function should throw an error. -\* We exclude the `password` field from this object to prevent it from being saved as plain-text in the database. The `password` field is handled by Wasp's auth backend. +\* We exclude the `password` field from this object to prevent it from being saved as plain text in the database. The `password` field is handled by Wasp's auth backend. ### `SignupForm` Customization diff --git a/web/docs/auth/social-auth/_api-reference-intro.md b/web/docs/auth/social-auth/_api-reference-intro.md index 3fb34065d4..f9b90f022b 100644 --- a/web/docs/auth/social-auth/_api-reference-intro.md +++ b/web/docs/auth/social-auth/_api-reference-intro.md @@ -1,10 +1,10 @@ Provider-specific behavior comes down to implementing two functions. - `configFn` -- `getUserFieldsFn` +- `userSignupFields` The reference shows how to define both. -For behavior common to all providers, check the general [API Reference](/docs/auth/overview.md#api-reference). +For behavior common to all providers, check the general [API Reference](../../auth/overview.md#api-reference). diff --git a/web/docs/auth/social-auth/_default-behaviour.md b/web/docs/auth/social-auth/_default-behaviour.md index 3ce6c65606..a5e2462374 100644 --- a/web/docs/auth/social-auth/_default-behaviour.md +++ b/web/docs/auth/social-auth/_default-behaviour.md @@ -1,10 +1,3 @@ When a user **signs in for the first time**, Wasp creates a new user account and links it to the chosen auth provider account for future logins. -Also, if the `userEntity` has: - -- A `username` field: Wasp sets it to a random username (e.g. `nice-blue-horse-14357`). -- A `password` field: Wasp sets it to a random string. - -This is a historical coupling between `auth` methods we plan to remove in the future. - diff --git a/web/docs/auth/social-auth/_getuserfields-type.md b/web/docs/auth/social-auth/_getuserfields-type.md index a630a3e562..091473d851 100644 --- a/web/docs/auth/social-auth/_getuserfields-type.md +++ b/web/docs/auth/social-auth/_getuserfields-type.md @@ -1,3 +1,3 @@ -Wasp automatically generates the type `GetUserFieldsFn` to help you correctly type your `getUserFields` function. +Wasp automatically generates the `defineUserSignupFields` function to help you correctly type your `userSignupFields` object. diff --git a/web/docs/auth/social-auth/_override-example-intro.md b/web/docs/auth/social-auth/_override-example-intro.md index 01fad70340..5b89b83a92 100644 --- a/web/docs/auth/social-auth/_override-example-intro.md +++ b/web/docs/auth/social-auth/_override-example-intro.md @@ -1,10 +1,10 @@ When a user logs in using a social login provider, the backend receives some data about the user. -Wasp lets you access this data inside the `getUserFieldsFn` function. +Wasp lets you access this data inside the `userSignupFields` getters. For example, the User entity can include a `displayName` field which you can set based on the details received from the provider. Wasp also lets you customize the configuration of the providers' settings using the `configFn` function. -Let's use this example to show both functions in action: +Let's use this example to show both fields in action: diff --git a/web/docs/auth/social-auth/_override-intro.md b/web/docs/auth/social-auth/_override-intro.md index 0c399d3ca0..ea438c6fc2 100644 --- a/web/docs/auth/social-auth/_override-intro.md +++ b/web/docs/auth/social-auth/_override-intro.md @@ -1,8 +1,8 @@ -Wasp lets you override the default behavior. You can create custom setups, such as allowing users to define a custom username rather instead of getting a randomly generated one. +By default, Wasp doesn't store any information it receives from the social login provider. It only stores the user's ID specific to the provider. -There are two mechanisms (functions) used for overriding the default behavior: +There are two mechanisms used for overriding the default behavior: -- `getUserFieldsFn` +- `userSignupFields` - `configFn` Let's explore them in more detail. diff --git a/web/docs/auth/social-auth/_using-auth-note.md b/web/docs/auth/social-auth/_using-auth-note.md index 8ddc130587..7fe740be18 100644 --- a/web/docs/auth/social-auth/_using-auth-note.md +++ b/web/docs/auth/social-auth/_using-auth-note.md @@ -1,3 +1,3 @@ -To read more about how to set up the logout button and get access to the logged-in user in both client and server code, read the docs on [using auth](/docs/auth/overview). +To read more about how to set up the logout button and get access to the logged-in user in both client and server code, read the docs on [using auth](../../auth/overview). diff --git a/web/docs/auth/social-auth/_wasp-file-structure-note.md b/web/docs/auth/social-auth/_wasp-file-structure-note.md index ea441c1309..b30c2c2daa 100644 --- a/web/docs/auth/social-auth/_wasp-file-structure-note.md +++ b/web/docs/auth/social-auth/_wasp-file-structure-note.md @@ -8,7 +8,6 @@ app myApp { // Defining entities entity User { ... } -entity SocialLogin { ... } // Defining routes and pages route LoginRoute { ... } diff --git a/web/docs/auth/social-auth/github.md b/web/docs/auth/social-auth/github.md index 67c067688c..2f2f6fe93e 100644 --- a/web/docs/auth/social-auth/github.md +++ b/web/docs/auth/social-auth/github.md @@ -8,9 +8,9 @@ import OverrideIntro from './\_override-intro.md'; import OverrideExampleIntro from './\_override-example-intro.md'; import UsingAuthNote from './\_using-auth-note.md'; import WaspFileStructureNote from './\_wasp-file-structure-note.md'; -import UsernameGenerateExplanation from './\_username-generate-explanation.md'; import GetUserFieldsType from './\_getuserfields-type.md'; import ApiReferenceIntro from './\_api-reference-intro.md'; +import UserSignupFieldsExplainer from '../\_user-signup-fields-explainer.md'; Wasp supports Github Authentication out of the box. GitHub is a great external auth choice when you're building apps for developers, as most of them already have a GitHub account. @@ -24,7 +24,7 @@ Let's walk through enabling Github Authentication, explain some of the default s Enabling GitHub Authentication comes down to a series of steps: 1. Enabling GitHub authentication in the Wasp file. -1. Adding the necessary Entities. +1. Adding the `User` entity. 1. Creating a GitHub OAuth app. 1. Adding the neccessary Routes and Pages 1. Using Auth UI components in our Pages. @@ -49,13 +49,9 @@ app myApp { // 1. Specify the User entity (we'll define it next) // highlight-next-line userEntity: User, - // highlight-next-line - // 2. Specify the SocialLogin entity (we'll define it next) - // highlight-next-line - externalAuthEntity: SocialLogin, methods: { // highlight-next-line - // 3. Enable Github Auth + // 2. Enable Github Auth // highlight-next-line gitHub: {} }, @@ -78,13 +74,9 @@ app myApp { // 1. Specify the User entity (we'll define it next) // highlight-next-line userEntity: User, - // highlight-next-line - // 2. Specify the SocialLogin entity (we'll define it next) - // highlight-next-line - externalAuthEntity: SocialLogin, methods: { // highlight-next-line - // 3. Enable Github Auth + // 2. Enable Github Auth // highlight-next-line gitHub: {} }, @@ -96,35 +88,20 @@ app myApp { -### 2. Add the Entities +### 2. Add the User Entity -Let's now define the entities acting as `app.auth.userEntity` and `app.auth.externalAuthEntity`: +Let's now define the `app.auth.userEntity` entity: ```wasp title="main.wasp" // ... -// highlight-next-line -// 4. Define the User entity +// 3. Define the User entity // highlight-next-line entity User {=psl id Int @id @default(autoincrement()) // ... - externalAuthAssociations SocialLogin[] -psl=} - -// highlight-next-line -// 5. Define the SocialLogin entity -// highlight-next-line -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=} ``` @@ -133,34 +110,17 @@ psl=} ```wasp title="main.wasp" // ... -// highlight-next-line -// 4. Define the User entity +// 3. Define the User entity // highlight-next-line entity User {=psl id Int @id @default(autoincrement()) // ... - externalAuthAssociations SocialLogin[] -psl=} - -// highlight-next-line -// 5. Define the SocialLogin entity -// highlight-next-line -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=} ``` -`externalAuthEntity` and `userEntity` are explained in [the social auth overview](/docs/auth/social-auth/overview#social-login-entity). - ### 3. Creating a GitHub OAuth App To use GitHub as an authentication method, you'll first need to create a GitHub OAuth App and provide Wasp with your client key and secret. Here's how you do it: @@ -231,7 +191,7 @@ We'll define the React components for these pages in the `client/pages/auth.{jsx ### 6. Creating the Client Pages :::info -We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](/docs/project/css-frameworks). +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../../project/css-frameworks). ::: Let's create a `auth.{jsx,tsx}` file in the `client/pages` folder and add the following to it: @@ -295,7 +255,7 @@ export function Layout({ children }: { children: React.ReactNode }) { -We imported the generated Auth UI component and used them in our pages. Read more about the Auth UI components [here](/docs/auth/ui). +We imported the generated Auth UI component and used them in our pages. Read more about the Auth UI components [here](../../auth/ui). ### Conclusion @@ -304,7 +264,7 @@ Yay, we've successfully set up Github Auth! 🎉 ![Github Auth](/img/auth/github.png) Running `wasp db migrate-dev` and `wasp start` should now give you a working app with authentication. -To see how to protect specific pages (i.e., hide them from non-authenticated users), read the docs on [using auth](/docs/auth/overview). +To see how to protect specific pages (i.e., hide them from non-authenticated users), read the docs on [using auth](../../auth/overview). ## Default Behaviour @@ -313,7 +273,7 @@ Add `gitHub: {}` to the `auth.methods` dictionary to use it with default setting -```wasp title=main.wasp {10} +```wasp title=main.wasp app myApp { wasp: { version: "^0.11.0" @@ -321,8 +281,8 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { + // highlight-next-line gitHub: {} }, onAuthFailedRedirectTo: "/login" @@ -333,7 +293,7 @@ app myApp { -```wasp title=main.wasp {10} +```wasp title=main.wasp app myApp { wasp: { version: "^0.11.0" @@ -341,8 +301,8 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { + // highlight-next-line gitHub: {} }, onAuthFailedRedirectTo: "/login" @@ -366,7 +326,7 @@ app myApp { -```wasp title="main.wasp" {11-12,22} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -374,11 +334,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { gitHub: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/github.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/github.js" } }, onAuthFailedRedirectTo: "/login" @@ -389,19 +350,15 @@ entity User {=psl id Int @id @default(autoincrement()) username String @unique displayName String - externalAuthAssociations SocialLogin[] psl=} // ... ``` ```js title=src/server/auth/github.js -import { generateAvailableDictionaryUsername } from "@wasp/core/auth.js"; - -export const getUserFields = async (_context, args) => { - const username = await generateAvailableDictionaryUsername(); - const displayName = args.profile.displayName; - return { username, displayName }; +export const userSignupFields = { + username: () => "hardcoded-username", + displayName: (data) => data.profile.displayName, }; export function getConfig() { @@ -416,7 +373,7 @@ export function getConfig() { -```wasp title="main.wasp" {11-12,22} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -424,11 +381,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { gitHub: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/github.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/github.js" } }, onAuthFailedRedirectTo: "/login" @@ -439,21 +397,18 @@ entity User {=psl id Int @id @default(autoincrement()) username String @unique displayName String - externalAuthAssociations SocialLogin[] psl=} // ... ``` ```ts title=src/server/auth/github.ts -import type { GetUserFieldsFn } from '@wasp/types' -import { generateAvailableDictionaryUsername } from '@wasp/core/auth.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const getUserFields: GetUserFieldsFn = async (_context, args) => { - const username = await generateAvailableDictionaryUsername() - const displayName = args.profile.displayName - return { username, displayName } -} +export const userSignupFields = defineUserSignupFields({ + username: () => "hardcoded-username", + displayName: (data) => data.profile.displayName, +}) export function getConfig() { return { @@ -480,7 +435,7 @@ export function getConfig() { -```wasp title="main.wasp" {11-12} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -488,11 +443,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { gitHub: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/github.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/github.js" } }, onAuthFailedRedirectTo: "/login" @@ -503,7 +459,7 @@ app myApp { -```wasp title="main.wasp" {11-12} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -511,11 +467,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { gitHub: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/github.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/github.js" } }, onAuthFailedRedirectTo: "/login" @@ -561,46 +518,6 @@ The `gitHub` dict has the following properties: -- #### `getUserFieldsFn: ServerImport` - - This function should return the user fields to use when creating a new user. - - The `context` contains the `User` entity, and the `args` object contains GitHub profile information. - You can do whatever you want with this information (e.g., generate a username). - - Here is how you could generate a username based on the Github display name: - - - - ```js title=src/server/auth/github.js - import { generateAvailableUsername } from '@wasp/core/auth.js' - - export const getUserFields = async (_context, args) => { - const username = await generateAvailableUsername( - args.profile.displayName.split(' '), - { separator: '.' } - ) - return { username } - } - ``` - - - - - ```ts title=src/server/auth/github.ts - import type { GetUserFieldsFn } from '@wasp/types' - import { generateAvailableUsername } from '@wasp/core/auth.js' - - export const getUserFields: GetUserFieldsFn = async (_context, args) => { - const username = await generateAvailableUsername( - args.profile.displayName.split(' '), - { separator: '.' } - ) - return { username } - } - ``` - - - +- #### `userSignupFields: ServerImport` - + \ No newline at end of file diff --git a/web/docs/auth/social-auth/google.md b/web/docs/auth/social-auth/google.md index 0d38c1d839..1beebf4471 100644 --- a/web/docs/auth/social-auth/google.md +++ b/web/docs/auth/social-auth/google.md @@ -8,9 +8,9 @@ import OverrideIntro from './\_override-intro.md'; import OverrideExampleIntro from './\_override-example-intro.md'; import UsingAuthNote from './\_using-auth-note.md'; import WaspFileStructureNote from './\_wasp-file-structure-note.md'; -import UsernameGenerateExplanation from './\_username-generate-explanation.md'; import GetUserFieldsType from './\_getuserfields-type.md'; import ApiReferenceIntro from './\_api-reference-intro.md'; +import UserSignupFieldsExplainer from '../\_user-signup-fields-explainer.md'; Wasp supports Google Authentication out of the box. Google Auth is arguably the best external auth option, as most users on the web already have Google accounts. @@ -24,7 +24,7 @@ Let's walk through enabling Google authentication, explain some of the default s Enabling Google Authentication comes down to a series of steps: 1. Enabling Google authentication in the Wasp file. -1. Adding the necessary Entities. +1. Adding the `User` entity. 1. Creating a Google OAuth app. 1. Adding the neccessary Routes and Pages 1. Using Auth UI components in our Pages. @@ -45,17 +45,11 @@ app myApp { }, title: "My App", auth: { - // highlight-next-line // 1. Specify the User entity (we'll define it next) // highlight-next-line userEntity: User, - // highlight-next-line - // 2. Specify the SocialLogin entity (we'll define it next) - // highlight-next-line - externalAuthEntity: SocialLogin, methods: { - // highlight-next-line - // 3. Enable Google Auth + // 2. Enable Google Auth // highlight-next-line google: {} }, @@ -74,17 +68,11 @@ app myApp { }, title: "My App", auth: { - // highlight-next-line // 1. Specify the User entity (we'll define it next) // highlight-next-line userEntity: User, - // highlight-next-line - // 2. Specify the SocialLogin entity (we'll define it next) - // highlight-next-line - externalAuthEntity: SocialLogin, methods: { - // highlight-next-line - // 3. Enable Google Auth + // 2. Enable Google Auth // highlight-next-line google: {} }, @@ -96,37 +84,22 @@ app myApp { -`externalAuthEntity` and `userEntity` are explained in [the social auth overview](/docs/auth/social-auth/overview#social-login-entity). +`userEntity` is explained in [the social auth overview](../../auth/social-auth/overview#social-login-entity). -### 2. Adding the Entities +### 2. Adding the User Entity -Let's now define the entities acting as `app.auth.userEntity` and `app.auth.externalAuthEntity`: +Let's now define the `app.auth.userEntity` entity: ```wasp title="main.wasp" // ... -// highlight-next-line -// 4. Define the User entity +// 3. Define the User entity // highlight-next-line entity User {=psl id Int @id @default(autoincrement()) // ... - externalAuthAssociations SocialLogin[] -psl=} - -// highlight-next-line -// 5. Define the SocialLogin entity -// highlight-next-line -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=} ``` @@ -135,26 +108,11 @@ psl=} ```wasp title="main.wasp" // ... -// highlight-next-line -// 4. Define the User entity +// 3. Define the User entity // highlight-next-line entity User {=psl id Int @id @default(autoincrement()) // ... - externalAuthAssociations SocialLogin[] -psl=} - -// highlight-next-line -// 5. Define the SocialLogin entity -// highlight-next-line -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=} ``` @@ -271,7 +229,7 @@ We'll define the React components for these pages in the `client/pages/auth.{jsx ### 6. Create the Client Pages :::info -We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](/docs/project/css-frameworks). +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../../project/css-frameworks). ::: Let's now create a `auth.{jsx,tsx}` file in the `client/pages`. @@ -337,7 +295,7 @@ export function Layout({ children }: { children: React.ReactNode }) { :::info Auth UI -Our pages use an automatically-generated Auth UI component. Read more about Auth UI components [here](/docs/auth/ui). +Our pages use an automatically-generated Auth UI component. Read more about Auth UI components [here](../../auth/ui). ::: ### Conclusion @@ -347,7 +305,7 @@ Yay, we've successfully set up Google Auth! 🎉 ![Google Auth](/img/auth/google.png) Running `wasp db migrate-dev` and `wasp start` should now give you a working app with authentication. -To see how to protect specific pages (i.e., hide them from non-authenticated users), read the docs on [using auth](/docs/auth/overview). +To see how to protect specific pages (i.e., hide them from non-authenticated users), read the docs on [using auth](../../auth/overview). ## Default Behaviour @@ -356,7 +314,7 @@ Add `google: {}` to the `auth.methods` dictionary to use it with default setting -```wasp title=main.wasp {10} +```wasp title=main.wasp app myApp { wasp: { version: "^0.11.0" @@ -364,8 +322,8 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { + // highlight-next-line google: {} }, onAuthFailedRedirectTo: "/login" @@ -376,7 +334,7 @@ app myApp { -```wasp title=main.wasp {10} +```wasp title=main.wasp app myApp { wasp: { version: "^0.11.0" @@ -384,8 +342,8 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { + // highlight-next-line google: {} }, onAuthFailedRedirectTo: "/login" @@ -409,7 +367,7 @@ app myApp { -```wasp title="main.wasp" {11-12,22} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -417,11 +375,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { google: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/google.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/google.js" } }, onAuthFailedRedirectTo: "/login" @@ -432,19 +391,15 @@ entity User {=psl id Int @id @default(autoincrement()) username String @unique displayName String - externalAuthAssociations SocialLogin[] psl=} // ... ``` ```js title=src/server/auth/google.js -import { generateAvailableDictionaryUsername } from '@wasp/core/auth.js' - -export const getUserFields = async (_context, args) => { - const username = await generateAvailableDictionaryUsername() - const displayName = args.profile.displayName - return { username, displayName } +export const userSignupFields = { + username: () => "hardcoded-username", + displayName: (data) => data.profile.displayName, } export function getConfig() { @@ -459,7 +414,7 @@ export function getConfig() { -```wasp title="main.wasp" {11-12,22} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -467,11 +422,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { google: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/google.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/google.js" } }, onAuthFailedRedirectTo: "/login" @@ -482,21 +438,18 @@ entity User {=psl id Int @id @default(autoincrement()) username String @unique displayName String - externalAuthAssociations SocialLogin[] psl=} // ... ``` ```ts title=src/server/auth/google.ts -import type { GetUserFieldsFn } from '@wasp/types' -import { generateAvailableDictionaryUsername } from '@wasp/core/auth.js' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const getUserFields: GetUserFieldsFn = async (_context, args) => { - const username = await generateAvailableDictionaryUsername() - const displayName = args.profile.displayName - return { username, displayName } -} +export const userSignupFields = defineUserSignupFields({ + username: () => "hardcoded-username", + displayName: (data) => data.profile.displayName, +}) export function getConfig() { return { @@ -523,7 +476,7 @@ export function getConfig() { -```wasp title="main.wasp" {11-12} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -531,11 +484,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { google: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/google.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/google.js" } }, onAuthFailedRedirectTo: "/login" @@ -546,7 +500,7 @@ app myApp { -```wasp title="main.wasp" {11-12} +```wasp title="main.wasp" app myApp { wasp: { version: "^0.11.0" @@ -554,11 +508,12 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { google: { + // highlight-next-line configFn: import { getConfig } from "@server/auth/google.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + // highlight-next-line + userSignupFields: import { userSignupFields } from "@server/auth/google.js" } }, onAuthFailedRedirectTo: "/login" @@ -604,48 +559,6 @@ The `google` dict has the following properties: -- #### `getUserFieldsFn: ServerImport` - - This function must return the user fields to use when creating a new user. - - The `context` contains the `User` entity, and the `args` object contains Google profile information. - You can do whatever you want with this information (e.g., generate a username). - - Here is how to generate a username based on the Google display name: - - - - ```js title=src/server/auth/google.js - import { generateAvailableUsername } from '@wasp/core/auth.js' - - export const getUserFields = async (_context, args) => { - const username = await generateAvailableUsername( - args.profile.displayName.split(' '), - { separator: '.' } - ) - return { username } - } - ``` - - - - - ```ts title=src/server/auth/google.ts - import type { GetUserFieldsFn } from '@wasp/types' - import { generateAvailableUsername } from '@wasp/core/auth.js' - - export const getUserFields: GetUserFieldsFn = async (_context, args) => { - const username = await generateAvailableUsername( - args.profile.displayName.split(' '), - { separator: '.' } - ) - return { username } - } - ``` - - - - - +- #### `userSignupFields: ServerImport` - + \ No newline at end of file diff --git a/web/docs/auth/social-auth/overview.md b/web/docs/auth/social-auth/overview.md index 9a89101e5b..c69a29ee11 100644 --- a/web/docs/auth/social-auth/overview.md +++ b/web/docs/auth/social-auth/overview.md @@ -23,15 +23,12 @@ Wasp currently supports the following social login providers: -## Social Login Entity +## User Entity Wasp requires you to declare a `userEntity` for all `auth` methods (social or otherwise). This field tells Wasp which Entity represents the user. -Additionally, when using `auth` methods that rely on external providers(e.g., _Google_), you must also declare an `externalAuthEntity`. -This tells Wasp which Entity represents the user's link with the social provider. - -Both fields fall under `app.auth`. Here's what the full setup looks like: +Here's what the full setup looks like: @@ -45,8 +42,6 @@ app myApp { auth: { // highlight-next-line userEntity: User, - // highlight-next-line - externalAuthEntity: SocialLogin, methods: { google: {} }, @@ -58,18 +53,6 @@ app myApp { entity User {=psl id Int @id @default(autoincrement()) //... - externalAuthAssociations SocialLogin[] -psl=} - -// highlight-next-line -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=} ``` @@ -85,8 +68,6 @@ app myApp { auth: { // highlight-next-line userEntity: User, - // highlight-next-line - externalAuthEntity: SocialLogin, methods: { google: {} }, @@ -98,18 +79,6 @@ app myApp { entity User {=psl id Int @id @default(autoincrement()) //... - externalAuthAssociations SocialLogin[] -psl=} - -// highlight-next-line -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=} ``` @@ -122,23 +91,23 @@ To learn more about what the fields on these entities represent, look at the [AP -:::note -Wasp uses the same `externalAuthEntity` for all social login providers (e.g. both GitHub and Google use the same entity). -::: - ## Default Behavior ## Overrides -Wasp lets you override the default behavior. You can create custom setups, such as allowing users to define a custom username rather instead of getting a randomly generated one. +By default, Wasp doesn't store any information it receives from the social login provider. It only stores the user's ID specific to the provider. + +If you wish to store more information about the user, you can override the default behavior. You can do this by defining the `userSignupFields` and `configFn` fields in `main.wasp` for each provider. + +You can create custom signup setups, such as allowing users to define a custom username after they sign up with a social provider. -### Allowing User to Set Their Username +### Example: Allowing User to Set Their Username If you want to modify the signup flow (e.g., let users choose their own usernames), you will need to go through three steps: -1. The first step is adding a `isSignupComplete` property to your `User` Entity. This field will signals whether the user has completed the signup process. +1. The first step is adding a `isSignupComplete` property to your `User` Entity. This field will signal whether the user has completed the signup process. 2. The second step is overriding the default signup behavior. 3. The third step is implementing the rest of your signup flow and redirecting users where appropriate. @@ -155,7 +124,6 @@ entity User {=psl username String? @unique // highlight-next-line isSignupComplete Boolean @default(false) - externalAuthAssociations SocialLogin[] psl=} ``` @@ -168,7 +136,6 @@ entity User {=psl username String? @unique // highlight-next-line isSignupComplete Boolean @default(false) - externalAuthAssociations SocialLogin[] psl=} ``` @@ -177,7 +144,7 @@ psl=} #### 2. Overriding the Default Behavior -Declare an import under `app.auth.methods.google.getUserFieldsFn` (the example assumes you're using Google): +Declare an import under `app.auth.methods.google.userSignupFields` (the example assumes you're using Google): @@ -190,11 +157,10 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { google: { // highlight-next-line - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + userSignupFields: import { userSignupFields } from "@server/auth/google.js" } }, onAuthFailedRedirectTo: "/login" @@ -207,10 +173,8 @@ app myApp { And implement the imported function. ```js title=src/server/auth/google.js -export const getUserFields = async (_context, _args) => { - return { - isSignupComplete: false, - } +export const userSignupFields = { + isSignupComplete: () => false, } ``` @@ -225,11 +189,10 @@ app myApp { title: "My App", auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { google: { // highlight-next-line - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + userSignupFields: import { userSignupFields } from "@server/auth/google.js" } }, onAuthFailedRedirectTo: "/login" @@ -242,13 +205,11 @@ app myApp { And implement the imported function: ```ts title=src/server/auth/google.ts -import { GetUserFieldsFn } from '@wasp/types' +import { defineUserSignupFields } from '@wasp/auth/index.js' -export const getUserFields: GetUserFieldsFn = async (_context, _args) => { - return { - isSignupComplete: false, - } -} +export const userSignupFields = defineUserSignupFields({ + isSignupComplete: () => false, +}) ``` @@ -258,7 +219,7 @@ export const getUserFields: GetUserFieldsFn = async (_context, _args) => { #### 3. Showing the Correct State on the Client -You can query the user's `isSignupComplete` flag on the client with the [`useAuth()`](/docs/auth/overview) hook. +You can query the user's `isSignupComplete` flag on the client with the [`useAuth()`](../../auth/overview) hook. Depending on the flag's value, you can redirect users to the appropriate signup step. For example: @@ -310,14 +271,14 @@ The same general principle applies to more complex signup procedures, just chang ### Using the User's Provider Account Details Account details are provider-specific. -Each provider has their own rules for defining the `getUserFieldsFn` and `configFn` functions: +Each provider has their own rules for defining the `userSignupFields` and `configFn` fields: ## UI Helpers :::tip Use Auth UI -[Auth UI](/docs/auth/ui) is a common name for all high-level auth forms that come with Wasp. +[Auth UI](../../auth/ui) is a common name for all high-level auth forms that come with Wasp. These include fully functional auto-generated login and signup forms with working social login buttons. If you're looking for the fastest way to get your auth up and running, that's where you should look. @@ -391,107 +352,8 @@ If you need even more customization, you can create your custom components using For more information on: - Allowed fields in `app.auth` -- `getUserFields` and `configFn` functions +- `userSignupFields` and `configFn` functions Check the provider-specific API References: - - -### The `externalAuthEntity` and Its Fields - -Using social login providers requires you to define _an External Auth Entity_ and declare it with the `app.auth.externalAuthEntity` field. -This Entity holds the data relevant to the social provider. -All social providers share the same Entity. - - - - -```wasp title=main.wasp {4-10} -// ... - -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=} - -// ... -``` - - - - -```wasp title=main.wasp {4-10} -// ... - -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=} - -// ... -``` - - - - -:::info -You don't need to know these details, you can just copy and paste the entity definition above and you are good to go. -::: - -The Entity acting as `app.auth.externalAuthEntity` must include the following fields: - -- `provider` - The provider's name (e.g. `google`, `github`, etc.). -- `providerId` - The user's ID on the provider's platform. -- `userId` - The user's ID on your platform (this references the `id` field from the Entity acting as `app.auth.userEntity`). -- `user` - A relation to the `userEntity` (see [the `userEntity` section](#expected-fields-on-the-userentity)) for more details. -- `createdAt` - A timestamp of when the association was created. -- `@@unique([provider, providerId, userId])` - A unique constraint on the combination of `provider`, `providerId` and `userId`. - -### Expected Fields on the `userEntity` - -Using Social login providers requires you to add one extra field to the Entity acting as `app.auth.userEntity`: - -- `externalAuthAssociations` - A relation to the `externalAuthEntity` (see [the `externalAuthEntity` section](#the-externalauthentity-and-its-fields) for more details). - - - - -```wasp title=main.wasp {6} -// ... - -entity User {=psl - id Int @id @default(autoincrement()) - //... - externalAuthAssociations SocialLogin[] -psl=} - -// ... -``` - - - - -```wasp title=main.wasp {6} -// ... - -entity User {=psl - id Int @id @default(autoincrement()) - //... - externalAuthAssociations SocialLogin[] -psl=} - -// ... -``` - - - + \ No newline at end of file diff --git a/web/docs/auth/ui.md b/web/docs/auth/ui.md index 2070a9ed3f..bb54cff98d 100644 --- a/web/docs/auth/ui.md +++ b/web/docs/auth/ui.md @@ -218,7 +218,7 @@ export function SignupPage() { It will automatically show the correct authentication providers based on your `main.wasp` file. -Read more about customizing the signup process like adding additional fields or extra UI in the [Using Auth](/docs/auth/overview#customizing-the-signup-process) section. +Read more about customizing the signup process like adding additional fields or extra UI in the [Auth Overview](../auth/overview#customizing-the-signup-process) section. ### Forgot Password Form diff --git a/web/docs/auth/username-and-pass.md b/web/docs/auth/username-and-pass.md index 4d7339293d..3d9efd8764 100644 --- a/web/docs/auth/username-and-pass.md +++ b/web/docs/auth/username-and-pass.md @@ -2,7 +2,12 @@ title: Username & Password --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; +import MultipleIdentitiesWarning from './\_multiple-identities-warning.md'; +import ReadMoreAboutAuthEntities from './\_read-more-about-auth-entities.md'; +import GetUsername from './entities/\_get-username.md'; +import UserSignupFieldsExplainer from './\_user-signup-fields-explainer.md'; +import UserFieldsExplainer from './\_user-fields.md'; Wasp supports username & password authentication out of the box with login and signup flows. It provides you with the server-side implementation and the UI components for the client-side. @@ -10,8 +15,8 @@ Wasp supports username & password authentication out of the box with login and s To set up username authentication we need to: 1. Enable username authentication in the Wasp file -1. Add the user entity -1. Add the routes and pages +1. Add the `User` entity +1. Add the auth routes and pages 1. Use Auth UI components in our pages Structure of the `main.wasp` file we will end up with: @@ -80,17 +85,16 @@ Read more about the `usernameAndPassword` auth method options [here](#fields-in- ### 2. Add the User Entity -When username authentication is enabled, Wasp expects certain fields in your `userEntity`. Let's add these fields to our `main.wasp` file: +The `User` entity can be as simple as including only the `id` field: -```wasp title="main.wasp" {4-5} +```wasp title="main.wasp" // 3. Define the user entity entity User {=psl + // highlight-next-line id Int @id @default(autoincrement()) - username String @unique - password String // Add your own fields below // ... psl=} @@ -98,12 +102,11 @@ psl=} -```wasp title="main.wasp" {4-5} +```wasp title="main.wasp" // 3. Define the user entity entity User {=psl + // highlight-next-line id Int @id @default(autoincrement()) - username String @unique - password String // Add your own fields below // ... psl=} @@ -111,7 +114,7 @@ psl=} -Read more about the `userEntity` fields [here](#userentity-fields). + ### 3. Add the Routes and Pages @@ -157,7 +160,7 @@ We'll define the React components for these pages in the `client/pages/auth.{jsx ### 4. Create the Client Pages :::info -We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](/docs/project/css-frameworks). +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../project/css-frameworks). ::: Let's create a `auth.{jsx,tsx}` file in the `client/pages` folder and add the following to it: @@ -255,23 +258,25 @@ export function Layout({ children }: { children: React.ReactNode }) { -We imported the generated Auth UI components and used them in our pages. Read more about the Auth UI components [here](/docs/auth/ui). +We imported the generated Auth UI components and used them in our pages. Read more about the Auth UI components [here](../auth/ui). ### Conclusion That's it! We have set up username authentication in our app. 🎉 -Running `wasp db migrate-dev` and then `wasp start` should give you a working app with username authentication. If you want to put some of the pages behind authentication, read the [using auth docs](/docs/auth/overview). +Running `wasp db migrate-dev` and then `wasp start` should give you a working app with username authentication. If you want to put some of the pages behind authentication, read the [auth overview docs](../auth/overview). + + ## Customizing the Auth Flow The login and signup flows are pretty standard: they allow the user to sign up and then log in with their username and password. The signup flow validates the username and password and then creates a new user entity in the database. -Read more about the default username and password validation rules in the [using auth docs](/docs/auth/overview#default-validations). +Read more about the default username and password validation rules in the [auth overview docs](../auth/overview#default-validations). If you require more control in your authentication flow, you can achieve that in the following ways: 1. Create your UI and use `signup` and `login` actions. -1. Create your custom sign-up action which use the Prisma client, along with your custom code. +1. Create your custom sign-up action which uses the lower-level API, along with your custom code. ### 1. Using the `signup` and `login` actions @@ -375,7 +380,7 @@ It takes one argument: - `password: string` :::info - By default, Wasp will only save the `username` and `password` fields. If you want to add extra fields to your signup process, read about [defining extra signup fields](/docs/auth/overview#customizing-the-signup-process). + By default, Wasp will only save the `username` and `password` fields. If you want to add extra fields to your signup process, read about [defining extra signup fields](../auth/overview#customizing-the-signup-process). ::: You can use it like this: @@ -471,7 +476,6 @@ The code of your custom sign-up action can look like this: action customSignup { fn: import { signup } from "@server/auth/signup.js", - entities: [User] } ``` @@ -482,19 +486,29 @@ import { ensureValidPassword, ensureValidUsername, } from '@wasp/auth/validation.js' +import { + createProviderId, + sanitizeAndSerializeProviderData, + createUser, +} from '@wasp/auth/utils.js' -export const signup = async (args, { entities: { User } }) => { +export const signup = async (args, _context) => { ensureValidUsername(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { - await User.create({ - data: { - username: args.username, - password: args.password, // Password is hashed automatically by Wasp - }, + const providerId = createProviderId('username', args.username) + const providerData = await sanitizeAndSerializeProviderData({ + hashedPassword: args.password, }) + + await createUser( + providerId, + providerData, + // Any additional data you want to store on the User entity + {}, + ) } catch (e) { return { success: false, @@ -519,7 +533,6 @@ export const signup = async (args, { entities: { User } }) => { action customSignup { fn: import { signup } from "@server/auth/signup.js", - entities: [User] } ``` @@ -529,6 +542,11 @@ import { ensureValidPassword, ensureValidUsername, } from '@wasp/auth/validation.js' +import { + createProviderId, + sanitizeAndSerializeProviderData, + createUser, +} from '@wasp/auth/utils.js' import type { CustomSignup } from '@wasp/actions/types' type CustomSignupInput = { @@ -543,19 +561,24 @@ type CustomSignupOutput = { export const signup: CustomSignup< CustomSignupInput, CustomSignupOutput -> = async (args, { entities: { User } }) => { +> = async (args, _context) => { ensureValidUsername(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { - await User.create({ - data: { - username: args.username, - password: args.password, // Password is hashed automatically by Wasp - }, + const providerId = createProviderId('username', args.username) + const providerData = await sanitizeAndSerializeProviderData<'username'>({ + hashedPassword: args.password, }) - } catch (e: any) { + + await createUser( + providerId, + providerData, + // Any additional data you want to store on the User entity + {}, + ) + } catch (e) { return { success: false, message: e.message, @@ -580,7 +603,7 @@ We suggest using the built-in field validators for your authentication flow. You - `ensureValidUsername(args)` - Checks if the username is valid and throws an error if it's not. Read more about the validation rules [here](/docs/auth/overview#default-validations). + Checks if the username is valid and throws an error if it's not. Read more about the validation rules [here](../auth/overview#default-validations). #### Password @@ -590,11 +613,19 @@ We suggest using the built-in field validators for your authentication flow. You - `ensureValidPassword(args)` - Checks if the password is valid and throws an error if it's not. Read more about the validation rules [here](/docs/auth/overview#default-validations). + Checks if the password is valid and throws an error if it's not. Read more about the validation rules [here](../auth/overview#default-validations). ## Using Auth -To read more about how to set up the logout button and how to get access to the logged-in user in our client and server code, read the [using auth docs](/docs/auth/overview). +To read more about how to set up the logout button and how to get access to the logged-in user in our client and server code, read the [auth overview docs](../auth/overview). + +### `getUsername` + +If you are looking to access the user's username in your code, you can do that by accessing the info about the user that is stored in the `user.auth.identities` array. + +To make things a bit easier for you, Wasp offers the `getUsername` helper. + + ## API Reference @@ -618,11 +649,8 @@ app myApp { } } -// Wasp requires the `userEntity` to have at least the following fields entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String psl=} ``` @@ -643,20 +671,14 @@ app myApp { } } -// Wasp requires the `userEntity` to have at least the following fields entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String psl=} ``` -Username & password auth requires that `userEntity` specified in `auth` contains: - -- `username` field of type `String` -- `password` field of type `String` + ### Fields in the `usernameAndPassword` dict @@ -672,7 +694,9 @@ app myApp { auth: { userEntity: User, methods: { - usernameAndPassword: {}, + usernameAndPassword: { + userSignupFields: import { userSignupFields } from "@server/auth/email.js", + }, }, onAuthFailedRedirectTo: "/login" } @@ -691,7 +715,9 @@ app myApp { auth: { userEntity: User, methods: { - usernameAndPassword: {}, + usernameAndPassword: { + userSignupFields: import { userSignupFields } from "@server/auth/email.js", + }, }, onAuthFailedRedirectTo: "/login" } @@ -701,8 +727,6 @@ app myApp { -:::info -`usernameAndPassword` dict doesn't have any options at the moment. -::: +#### `userSignupFields: ServerImport` -You can read about the rest of the `auth` options in the [using auth](/docs/auth/overview) section of the docs. + \ No newline at end of file diff --git a/web/docs/contributing.md b/web/docs/contributing.md index 4dc0a25c56..2325a17861 100644 --- a/web/docs/contributing.md +++ b/web/docs/contributing.md @@ -4,7 +4,7 @@ sidebar_label: Contributing slug: /contributing --- -import DiscordLink from '../blog/components/DiscordLink'; +import DiscordLink from '@site/blog/components/DiscordLink'; Any way you want to contribute is a good way, and we'd be happy to meet you! A single entry point for all contributors is the [CONTRIBUTING.md](https://github.com/wasp-lang/wasp/blob/main/CONTRIBUTING.md) file in our Github repo. All the requirements and instructions are there, so please check [CONTRIBUTING.md](https://github.com/wasp-lang/wasp/blob/main/CONTRIBUTING.md) for more details. @@ -16,4 +16,4 @@ Some side notes to make your journey easier: 3. If there's something you'd like to bring to our attention, go to [docs GitHub repo](https://github.com/wasp-lang/wasp) and make an issue/PR! -Happy hacking! \ No newline at end of file +Happy hacking! diff --git a/web/docs/data-model/backends.md b/web/docs/data-model/backends.md index acfb036a2f..672f2998cc 100644 --- a/web/docs/data-model/backends.md +++ b/web/docs/data-model/backends.md @@ -2,9 +2,9 @@ title: Databases --- -import { Required } from '@site/src/components/Required' +import { Required } from '@site/src/components/Tag' -[Entities](/docs/data-model/entities.md), [Operations](/docs/data-model/operations/overview) and [Automatic CRUD](/docs/data-model/crud.md) together make a high-level interface for working with your app's data. Still, all that data has to live somewhere, so let's see how Wasp deals with databases. +[Entities](../data-model/entities.md), [Operations](../data-model/operations/overview) and [Automatic CRUD](../data-model/crud.md) together make a high-level interface for working with your app's data. Still, all that data has to live somewhere, so let's see how Wasp deals with databases. ## Supported Database Backends @@ -77,7 +77,7 @@ Also, make sure that: If you want to spin up your own dev database (or connect to an external one), you can tell Wasp about it using the `DATABASE_URL` environment variable. Wasp will use the value of `DATABASE_URL` as a connection string. -The easiest way to set the necessary `DATABASE_URL` environment variable is by adding it to the [.env.server](/docs/project/env-vars) file in the root dir of your Wasp project (if that file doesn't yet exist, create it). +The easiest way to set the necessary `DATABASE_URL` environment variable is by adding it to the [.env.server](../project/env-vars) file in the root dir of your Wasp project (if that file doesn't yet exist, create it). Alternatively, you can set it inline when running `wasp` (this applies to all environment variables): diff --git a/web/docs/data-model/crud.md b/web/docs/data-model/crud.md index 41c0b61c19..cc83ef5a92 100644 --- a/web/docs/data-model/crud.md +++ b/web/docs/data-model/crud.md @@ -2,15 +2,15 @@ title: Automatic CRUD --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; import { ShowForTs } from '@site/src/components/TsJsHelpers'; -import ImgWithCaption from '../../blog/components/ImgWithCaption' +import ImgWithCaption from '@site/blog/components/ImgWithCaption' If you have a lot of experience writing full-stack apps, you probably ended up doing some of the same things many times: listing data, adding data, editing it, and deleting it. Wasp makes handling these boring bits easy by offering a higher-level concept called Automatic CRUD. -With a single declaration, you can tell Wasp to automatically generate server-side logic (i.e., Queries and Actions) for creating, reading, updating and deleting [Entities](/docs/data-model/entities). As you update definitions for your Entities, Wasp automatically regenerates the backend logic. +With a single declaration, you can tell Wasp to automatically generate server-side logic (i.e., Queries and Actions) for creating, reading, updating and deleting [Entities](../data-model/entities). As you update definitions for your Entities, Wasp automatically regenerates the backend logic. :::caution Early preview This feature is currently in early preview and we are actively working on it. Read more about [our plans](#future-of-crud-operations-in-wasp) for CRUD operations. @@ -62,7 +62,7 @@ Keep reading for an example of Automatic CRUD in action, or skip ahead for the [ ## Example: A Simple TODO App -Let's create a full-app example that uses automatic CRUD. We'll stick to using the `Task` entity from the previous example, but we'll add a `User` entity and enable [username and password](/docs/auth/username-and-pass) based auth. +Let's create a full-app example that uses automatic CRUD. We'll stick to using the `Task` entity from the previous example, but we'll add a `User` entity and enable [username and password](../auth/username-and-pass) based auth. @@ -89,8 +89,6 @@ app tasksCrudApp { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String tasks Task[] psl=} @@ -328,7 +326,7 @@ export const MainPage = () => { -And here are the login and signup pages, where we are using Wasp's [Auth UI](/docs/auth/ui) components: +And here are the login and signup pages, where we are using Wasp's [Auth UI](../auth/ui) components: @@ -692,7 +690,7 @@ export const getAllOverride: GetAllQuery = async ( -For a usage example, check the [example guide](/docs/data-model/crud#adding-crud-to-the-task-entity-). +For a usage example, check the [example guide](../data-model/crud#adding-crud-to-the-task-entity-). #### Using the CRUD operations in client code @@ -742,7 +740,7 @@ const deleteAction = Tasks.delete.useAction() -All CRUD operations are implemented with [Queries and Actions](/docs/data-model/operations/overview) under the hood, which means they come with all the features you'd expect (e.g., automatic SuperJSON serialization, full-stack type safety when using TypeScript) +All CRUD operations are implemented with [Queries and Actions](../data-model/operations/overview) under the hood, which means they come with all the features you'd expect (e.g., automatic SuperJSON serialization, full-stack type safety when using TypeScript) --- diff --git a/web/docs/data-model/entities.md b/web/docs/data-model/entities.md index 78a15c8bac..23843da178 100644 --- a/web/docs/data-model/entities.md +++ b/web/docs/data-model/entities.md @@ -62,11 +62,11 @@ Let's see how you can define and work with Wasp Entities: 1. Create/update some Entities in your `.wasp` file. 2. Run `wasp db migrate-dev`. This command syncs the database model with the Entity definitions in your `.wasp` file. It does this by creating migration scripts. 3. Migration scripts are automatically placed in the `migrations/` folder. Make sure to commit this folder into version control. -4. Use Wasp's JavasScript API to work with the database when implementing Operations (we'll cover this in detail when we talk about [operations](/docs/data-model/operations/overview)). +4. Use Wasp's JavasScript API to work with the database when implementing Operations (we'll cover this in detail when we talk about [operations](../data-model/operations/overview)). #### Using Entities in Operations -Most of the time, you will be working with Entities within the context of [Operations (Queries & Actions)](/docs/data-model/operations/overview). We'll see how that's done on the next page. +Most of the time, you will be working with Entities within the context of [Operations (Queries & Actions)](../data-model/operations/overview). We'll see how that's done on the next page. #### Using Entities directly diff --git a/web/docs/data-model/operations/actions.md b/web/docs/data-model/operations/actions.md index 13f9cffe92..fb0b378fd4 100644 --- a/web/docs/data-model/operations/actions.md +++ b/web/docs/data-model/operations/actions.md @@ -2,13 +2,13 @@ title: Actions --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; import { ShowForTs } from '@site/src/components/TsJsHelpers'; import SuperjsonNote from './\_superjson-note.md'; We'll explain what Actions are and how to use them. If you're looking for a detailed API specification, skip ahead to the [API Reference](#api-reference). -Actions are quite similar to [Queries](/docs/data-model/operations/queries.md), but with a key distinction: Actions are designed to modify and add data, while Queries are solely for reading data. Examples of Actions include adding a comment to a blog post, liking a video, or updating a product's price. +Actions are quite similar to [Queries](../../data-model/operations/queries.md), but with a key distinction: Actions are designed to modify and add data, while Queries are solely for reading data. Examples of Actions include adding a comment to a blog post, liking a video, or updating a product's price. Actions and Queries work together to keep data caches up-to-date. @@ -373,7 +373,7 @@ export const createTask: CreateTask = async (args, context) => { ### Using Entities in Actions -In most cases, resources used in Actions will be [Entities](/docs/data-model/entities.md). +In most cases, resources used in Actions will be [Entities](../../data-model/entities.md). To use an Entity in your Action, add it to the `action` declaration in Wasp: @@ -626,7 +626,7 @@ Since both arguments are positional, you can name the parameters however you wan 2. `context` (type depends on the Action) - An additional context object **passed into the Action by Wasp**. This object contains user session information, as well as information about entities. Check the [section about using entities in Actions](#using-entities-in-actions) to see how to use the entities field on the `context` object, or the [auth section](/docs/auth/overview#using-the-contextuser-object) to see how to use the `user` object. + An additional context object **passed into the Action by Wasp**. This object contains user session information, as well as information about entities. Check the [section about using entities in Actions](#using-entities-in-actions) to see how to use the entities field on the `context` object, or the [auth section](../../auth/overview#using-the-contextuser-object) to see how to use the `user` object. @@ -704,7 +704,7 @@ In this case, the Action expects to receive an object with a `bar` field of type ### The `useAction` Hook and Optimistic Updates -Make sure you understand how [Queries](/docs/data-model/operations/queries.md) and [Cache Invalidation](#cache-invalidation) work before reading this chapter. +Make sure you understand how [Queries](../../data-model/operations/queries.md) and [Cache Invalidation](#cache-invalidation) work before reading this chapter. When using Actions in components, you can enhance them with the help of the `useAction` hook. This hook comes bundled with Wasp, and is used for decorating Wasp Actions. In other words, the hook returns a function whose API matches the original Action while also doing something extra under the hood (depending on how you configure it). diff --git a/web/docs/data-model/operations/overview.md b/web/docs/data-model/operations/overview.md index 42d933a5a1..2bf1fa44d5 100644 --- a/web/docs/data-model/operations/overview.md +++ b/web/docs/data-model/operations/overview.md @@ -2,11 +2,11 @@ title: Overview --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; While Entities enable help you define your app's data model and relationships, Operations are all about working with this data. -There are two kinds of Operations: [Queries](/docs/data-model/operations/queries.md) and [Actions](/docs/data-model/operations/actions.md). As their names suggest, +There are two kinds of Operations: [Queries](../../data-model/operations/queries.md) and [Actions](../../data-model/operations/actions.md). As their names suggest, Queries are meant for reading data, and Actions are meant for changing it (either by updating existing entries or creating new ones). Keep reading to find out all there is to know about Operations in Wasp. diff --git a/web/docs/data-model/operations/queries.md b/web/docs/data-model/operations/queries.md index 8d25f6aa61..7987d032d2 100644 --- a/web/docs/data-model/operations/queries.md +++ b/web/docs/data-model/operations/queries.md @@ -2,7 +2,7 @@ title: Queries --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; import { ShowForTs } from '@site/src/components/TsJsHelpers'; import SuperjsonNote from './\_superjson-note.md'; @@ -15,7 +15,7 @@ Fetching all comments on a blog post, a list of users that liked a video, inform Queries are fairly similar to Actions in terms of their API. Therefore, if you're already familiar with Actions, you might find reading the entire guide repetitive. -We instead recommend skipping ahead and only reading [the differences between Queries and Actions](/docs/data-model/operations/actions#differences-between-queries-and-actions), and consulting the [API Reference](#api-reference) as needed. +We instead recommend skipping ahead and only reading [the differences between Queries and Actions](../../data-model/operations/actions#differences-between-queries-and-actions), and consulting the [API Reference](#api-reference) as needed. ::: ## Working with Queries @@ -395,7 +395,7 @@ To prevent information leakage, the server won't forward these fields for any ot ### Using Entities in Queries -In most cases, resources used in Queries will be [Entities](/docs/data-model/entities.md). +In most cases, resources used in Queries will be [Entities](../../data-model/entities.md). To use an Entity in your Query, add it to the `query` declaration in Wasp: @@ -552,7 +552,7 @@ Since both arguments are positional, you can name the parameters however you wan 2. `context` (type depends on the Query) - An additional context object **passed into the Query by Wasp**. This object contains user session information, as well as information about entities. Check the [section about using entities in Queries](#using-entities-in-queries) to see how to use the entities field on the `context` object, or the [auth section](/docs/auth/overview#using-the-contextuser-object) to see how to use the `user` object. + An additional context object **passed into the Query by Wasp**. This object contains user session information, as well as information about entities. Check the [section about using entities in Queries](#using-entities-in-queries) to see how to use the entities field on the `context` object, or the [auth section](../../auth/overview#using-the-contextuser-object) to see how to use the `user` object. @@ -651,6 +651,6 @@ Wasp's `useQuery` hook accepts three arguments: [the default behavior](https://react-query.tanstack.com/guides/important-defaults) for this particular Query. If you want to change the global defaults, you can do - so in the [client setup function](/docs/project/client-config.md#overriding-default-behaviour-for-queries). + so in the [client setup function](../../project/client-config.md#overriding-default-behaviour-for-queries). For an example of usage, check [this section](#the-usequery-hook). diff --git a/web/docs/examples.md b/web/docs/examples.md deleted file mode 100644 index 8bd07753c1..0000000000 --- a/web/docs/examples.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: Examples ---- - -import useBaseUrl from '@docusaurus/useBaseUrl'; - -We have a constantly growing collection of fully-functioning example apps, which you can use to learn more about Wasp's features. - -The full list of examples can be found [here](https://github.com/wasp-lang/wasp/tree/release/examples/). Here is a few of them: - -## Todo App - - **Features**: Auth ([username/password](language/features#authentication--authorization)), [Queries & Actions](language/features#queries-and-actions-aka-operations), [Entities](language/features#entity), [Routes](language/features#route) - - JS source code: [GitHub](https://github.com/wasp-lang/wasp/tree/release/examples/tutorials/TodoApp) - - TS source code: [GitHub](https://github.com/wasp-lang/wasp/tree/release/examples/todo-typescript) - - in-browser dev environment: [GitPod](https://gitpod.io/#https://github.com/wasp-lang/gitpod-template) - -## Waspello (Trello Clone) - - **Features**: Auth ([Google](language/features#social-login-providers-oauth-20), [username/password](language/features#authentication--authorization)), [Optimistic Updates](language/features#the-useaction-hook), [Tailwind CSS integration](/docs/project/css-frameworks) - - Source code: [GitHub](https://github.com/wasp-lang/wasp/tree/main/examples/waspello) - - Hosted at [https://waspello-demo.netlify.app](https://waspello-demo.netlify.app/login) -

    - -

    - -## Waspleau (Realtime Statistics Dashboard) - - **Features**: Cron [Jobs](language/features#jobs), [Server Setup](language/features#server-configuration) - - Source code: [GitHub](https://github.com/wasp-lang/wasp/tree/main/examples/waspleau) - - Hosted at [https://waspleau-app-client.fly.dev/](https://waspleau-app-client.fly.dev/) -

    - -

    \ No newline at end of file diff --git a/web/docs/general/cli.md b/web/docs/general/cli.md index cf0c94ed2b..d7b8db6d15 100644 --- a/web/docs/general/cli.md +++ b/web/docs/general/cli.md @@ -5,7 +5,7 @@ This guide provides an overview of the Wasp CLI commands, arguments, and options ## Overview -Once [installed](/docs/quick-start), you can use the wasp command from your command line. +Once [installed](../quick-start), you can use the wasp command from your command line. If you run the `wasp` command without any arguments, it will show you a list of available commands and their descriptions: @@ -101,13 +101,13 @@ Newsletter: https://wasp-lang.dev/#signup Deleted .wasp/ directory. ``` - - `wasp build` generates the complete web app code, which is ready for [deployment](/docs/advanced/deployment/overview). Use this command when you're deploying or ejecting. The generated code is stored in the `.wasp/build` folder. + - `wasp build` generates the complete web app code, which is ready for [deployment](../advanced/deployment/overview). Use this command when you're deploying or ejecting. The generated code is stored in the `.wasp/build` folder. - `wasp deploy` makes it easy to get your app hosted on the web. Currently, Wasp offers support for [Fly.io](https://fly.io). If you prefer a different hosting provider, feel free to let us know on Discord or submit a PR by updating [this TypeScript app](https://github.com/wasp-lang/wasp/tree/main/waspc/packages/deploy). - Read more about automatic deployment [here](/docs/advanced/deployment/cli). + Read more about automatic deployment [here](../advanced/deployment/cli). - `wasp telemetry` displays the status of [telemetry](https://wasp-lang.dev/docs/telemetry). diff --git a/web/docs/introduction/editor-setup.md b/web/docs/introduction/editor-setup.md index 15e49ef011..f025eccd32 100644 --- a/web/docs/introduction/editor-setup.md +++ b/web/docs/introduction/editor-setup.md @@ -4,7 +4,7 @@ slug: /editor-setup --- :::note -This page assumes you have already installed Wasp. If you do not have Wasp installed yet, check out the [Quick Start](/docs/quick-start) guide. +This page assumes you have already installed Wasp. If you do not have Wasp installed yet, check out the [Quick Start](./quick-start.md) guide. ::: Wasp comes with the Wasp language server, which gives supported editors powerful support and integration with the language. diff --git a/web/docs/introduction/introduction.md b/web/docs/introduction/introduction.md new file mode 100644 index 0000000000..29e2829f77 --- /dev/null +++ b/web/docs/introduction/introduction.md @@ -0,0 +1,204 @@ +--- +title: Introduction +slug: / +--- + +import ImgWithCaption from '@site/blog/components/ImgWithCaption' + +:::note +If you are looking for the installation instructions, check out the [Quick Start](./quick-start.md) section. +::: + +We will give a brief overview of what Wasp is, how it works on a high level and when to use it. + +## Wasp is a tool to build modern web applications + +It is an opinionated way of building **full-stack web applications**. It takes care of all three +major parts of a web application: **client** (front-end), **server** (back-end) and **database**. + +### Works well with your existing stack +Wasp is not trying to do everything at once but rather focuses on the complexity +which arises from connecting all the parts of the stack (client, server, database, deployment) together. + +Wasp is using **React**, **Node.js** and **Prisma** under the hood and relies on them to define web components and server queries and actions. + +### Wasp's secret sauce + +At the core is the Wasp compiler which takes the Wasp config and your Javascript code and outputs the client app, server app and deployment code. + + + + + +The cool thing about having a compiler that understands your code is that it can do a lot of things for you. + +Define your app in the Wasp config and get: +- login and signup with Auth UI components, +- full-stack type safety, +- e-mail sending, +- async processing jobs, +- React Query powered data fetching, +- security best practices, +- and more. + +You don't need to write any code for these features, Wasp will take care of it for you 🤯 And what's even better, Wasp also maintains the code for you, so you don't have to worry about keeping up with the latest security best practices. As Wasp updates, so does your app. + +## So what does the code look like? + +Let's say you want to build a web app that allows users to **create and share their favorite recipes**. + +Let's start with the main.wasp file: it is the central file of your app, where you describe the app from the high level. + +Let's give our app a title and let's immediately turn on the full-stack authentication via username and password: +```wasp title="main.wasp" +app RecipeApp { + title: "My Recipes", + wasp: { version: "^0.11.0" }, + auth: { + methods: { usernameAndPassword: {} }, + onAuthFailedRedirectTo: "/login", + userEntity: User + } +} +``` + +Let's then add the data models for your recipes. We will want to have Users and Users can own Recipes: + +```wasp title="main.wasp" +... + +entity User {=psl // Data models are defined using Prisma Schema Language. + id Int @id @default(autoincrement()) + recipes Recipe[] +psl=} + +entity Recipe {=psl + id Int @id @default(autoincrement()) + title String + description String? + userId Int + user User @relation(fields: [userId], references: [id]) +psl=} +``` + +Next, let's define how to do something with these data models! + +We do that by defining Operations, in this case a Query `getRecipes` and Action `addRecipe`, +which are in their essence a Node.js functions that execute on server and can, thanks to Wasp, very easily be called from the client. + +First, we define these Operations in our main.wasp file, so Wasp knows about them and can "beef them up": +```wasp title="main.wasp" +// Queries have automatic cache invalidation and are type-safe. +query getRecipes { + fn: import { getRecipes } from "@server/recipe.js", + entities: [Recipe], +} + +// Actions are type-safe and can be used to perform side-effects. +action addRecipe { + fn: import { addRecipe } from "@server/recipe.js", + entities: [Recipe], +} +``` + +... and then implement them in our Javascript (or TypeScript) code (we show just the query here, using TypeScript): + +```ts title="src/server/recipe.ts" +// Wasp generates types for you. +import type { GetRecipes } from "@wasp/queries/types"; +import type { Recipe } from "@wasp/entities"; + +export const getRecipes: GetRecipes<{}, Recipe[]> = async (_args, context) => { + return context.entities.Recipe.findMany( // Prisma query + { where: { user: { id: context.user.id } } } + ); +}; + +export const addRecipe ... +``` + +Now we can very easily use these in our React components! + +For the end, let's create a home page of our app. + +First we define it in main.wasp: +```wasp title="main.wasp" +... + +route HomeRoute { path: "/", to: HomePage } +page HomePage { + component: import { HomePage } from "@client/pages/HomePage", + authRequired: true // Will send user to /login if not authenticated. +} +``` + +and then implement it as a React component in JS/TS (that calls the Operations we previously defined): + +```tsx title="src/client/pages/HomePage.tsx" +import getRecipes from "@wasp/queries/getRecipes"; +import { useQuery } from "@wasp/queries"; +import type { User } from "@wasp/entities"; + +export function HomePage({ user }: { user: User }) { + // Due to full-stack type safety, `recipes` will be of type `Recipe[]` here. + const { data: recipes, isLoading } = useQuery(getRecipes); // Calling our query here! + + if (isLoading) { + return
    Loading...
    ; + } + + return ( +
    +

    Recipes

    +
      + {recipes ? recipes.map((recipe) => ( +
    • +
      {recipe.title}
      +
      {recipe.description}
      +
    • + )) : 'No recipes defined yet!'} +
    +
    + ); +} +``` + +And voila! We are listing all the recipes in our app 🎉 + +This was just a quick example to give you a taste of what Wasp is. For step by step tour through the most important Wasp features, check out the [Todo app tutorial](../tutorial/01-create.md). + +:::note +Above we skipped defining /login and /signup pages to keep the example a bit shorter, but those are very simple to do by using Wasp's Auth UI feature. +::: + +## When to use Wasp +Wasp is addressing the same core problems that typical web app frameworks are addressing, and it in big part [looks, swims and quacks](https://en.wikipedia.org/wiki/Duck_test) like a web app framework. + +### Best used for +- building full-stack web apps (like e.g. Airbnb or Asana) +- quickly starting a web app with industry best practices +- to be used alongside modern web dev stack (currently supported React and Node) + +### Avoid using Wasp for +- building static/presentational websites +- to be used as a no-code solution +- to be a solve-it-all tool in a single language + +## Wasp is a DSL + +:::note +You don't need to know what a DSL is to use Wasp, but if you are curious, you can read more about it below. +::: + +Wasp does not match typical expectations of a web app framework: it is not a set of libraries, it is instead a programming language that understands your code and can do a lot of things for you. + +Wasp is a programming language, but a specific kind: it is specialized for a single purpose: **building modern web applications**. We call such languages *DSL*s (Domain Specific Language). + +Other examples of *DSL*s that are often used today are e.g. *SQL* for databases and *HTML* for web page layouts. +The main advantage and reason why *DSL*s exist is that they need to do only one task (e.g. database queries) +so they can do it well and provide the best possible experience for the developer. + +The same idea stands behind Wasp - a language that will allow developers to **build modern web applications with 10x less code and less stack-specific knowledge**. diff --git a/web/docs/introduction/getting-started.md b/web/docs/introduction/quick-start.md similarity index 75% rename from web/docs/introduction/getting-started.md rename to web/docs/introduction/quick-start.md index be3c96872a..df6a3cdc42 100644 --- a/web/docs/introduction/getting-started.md +++ b/web/docs/introduction/quick-start.md @@ -45,8 +45,8 @@ Check [More Details](#more-details) section below if anything went wrong with th ### What next? - - [ ] 👉 **Check out the [Todo App tutorial](/docs/tutorial/create), which will take you through all the core features of Wasp!** 👈 - - [ ] [Setup your editor](/docs/editor-setup) for working with Wasp. + - [ ] 👉 **Check out the [Todo App tutorial](../tutorial/01-create.md), which will take you through all the core features of Wasp!** 👈 + - [ ] [Setup your editor](./editor-setup.md) for working with Wasp. - [ ] Join us on [Discord](https://discord.gg/rzdnErX)! Any feedback or questions you have, we are there for you. - [ ] Follow Wasp development by subscribing to our newsletter: https://wasp-lang.dev/#signup . We usually send 1 per month, and [Matija](https://github.com/matijaSos) does his best to unleash his creativity to make them engaging and fun to read :D! @@ -110,14 +110,22 @@ Open your terminal and run: curl -sSL https://get.wasp-lang.dev/installer.sh | sh ``` +:::note Running Wasp on Mac with Mx chip (arm64) +**Experiencing the 'Bad CPU type in executable' issue on a device with arm64 (Apple Silicon)?** +Given that the wasp binary is built for x86 and not for arm64 (Apple Silicon), you'll need to install [Rosetta on your Mac](https://support.apple.com/en-us/HT211861) if you are using a Mac with Mx (M1, M2, ...). Rosetta is a translation process that enables users to run applications designed for x86 on arm64 (Apple Silicon). To install Rosetta, run the following command in your terminal +```bash +softwareupdate --install-rosetta +``` +Once Rosetta is installed, you should be able to run Wasp without any issues. +::: + With Wasp for Windows, we are almost there: Wasp is successfully compiling and running on Windows but there is a bug or two stopping it from fully working. Check it out [here](https://github.com/wasp-lang/wasp/issues/48) if you are interested in helping. -In the meantime, the best way to start using Wasp on Windows is by using [WSL](https://docs.microsoft.com/en-us/windows/wsl/install-win10). Once you set up Ubuntu on WSL, just follow Linux instructions for installing Wasp. If you need further help, reach out to us on [Discord](https://discord.gg/rzdnErX) - we have some community members using WSL that might be able to help you. - +In the meantime, the best way to start using Wasp on Windows is by using [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). Once you set up Ubuntu on WSL, just follow Linux instructions for installing Wasp. You can refer to this [article](https://wasp-lang.dev/blog/2023/11/21/guide-windows-development-wasp-wsl) if you prefer a step by step guide to using Wasp in WSL environment. If you need further help, reach out to us on [Discord](https://discord.gg/rzdnErX) - we have some community members using WSL that might be able to help you. :::caution If you are using WSL2, make sure that your Wasp project is not on the Windows file system, but instead on the Linux file system. Otherwise, Wasp won't be able to detect file changes, due to the [issue in WSL2](https://github.com/microsoft/WSL/issues/4739). ::: diff --git a/web/docs/language/features.md b/web/docs/language/features.md deleted file mode 100644 index 43a2046cca..0000000000 --- a/web/docs/language/features.md +++ /dev/null @@ -1,2372 +0,0 @@ ---- -title: Features ---- - -import SendingEmailsInDevelopment from '../_sendingEmailsInDevelopment.md' -import OldDocsNote from '@site/docs/OldDocsNote' - - - -## App - -There can be only one declaration of `app` type per Wasp project. -It serves as a starting point and defines global properties of your app. - -```wasp -app todoApp { - wasp: { - version: "^0.6.0" - }, - title: "ToDo App", - head: [ // optional - "" - ] -} -``` - -### Fields - -#### `wasp: dict` (required) -Wasp compiler configuration. It is a dictionary with a single field: -- `version: string` (required) - version declares the compatible Wasp versions for the app. It should contain a valid [SemVer range](https://github.com/npm/node-semver#ranges). - -:::info -For now, the version field only supports caret ranges (i.e., `^x.y.z`). Support for the full specification will come in a future version of Wasp -::: - -#### `title: string` (required) -Title of your app. It will be displayed in the browser tab, next to the favicon. - -#### `head: [string]` (optional) -Head of your HTML Document. Your app's metadata (styles, links, etc) can be added here. - -#### `auth: dict` (optional) -Authentication and authorization configuration. -Check [`app.auth`](/docs/language/features#authentication--authorization) for more details. - -#### `client: dict` (optional) -Client configuration. -Check [`app.client`](/docs/language/features#client-configuration) for more details. - -#### `server: dict` (optional) -Server configuration. -Check [`app.server`](/docs/language/features#server-configuration) for more details. - -#### `db: dict` (optional) -Database configuration. -Check [`app.db`](/docs/language/features#database-configuration) for more details. - -#### `dependencies: [(string, string)]` (optional) -List of dependencies (external libraries). -Check [`app.dependencies`](/docs/language/features#dependencies) for more details. - -#### `emailSender: dict` (optional) -Email sender configuration. -Check [`app.emailSender`](/docs/language/features#email-sender) for more details. - -#### `webSocket: dict` (optional) -WebSocket configuration. -Check out the [`WebSocket guide`](/docs/guides/websockets) for more details. - -## Page - -`page` declaration is the top-level layout abstraction. Your app can have multiple pages. - -```wasp -page MainPage { - component: import Main from "@client/pages/Main", - authRequired: false // optional -} -``` - -Normally you will also want to associate `page` with a `route`, otherwise it won't be accessible in the app. - -### Fields - -#### `component: ClientImport` (required) -Import statement of the React element that implements the page component. - -#### `authRequired: bool` (optional) -Can be specified only if [`app.auth`](/docs/language/features#authentication--authorization) is defined. - -If set to `true`, only authenticated users will be able to access this page. Unauthenticated users will be redirected to a route defined by `onAuthFailedRedirectTo` property within `app.auth`. - -If `authRequired` is set to `true`, the React component of a page (specified by `component` property) will be provided `user` object as a prop. - -Check out this [section of our Todo app tutorial](/docs/tutorial/auth#update-the-main-page-to-require-auth) for an example of usage. - -## Route - -`route` declaration provides top-level routing functionality in Wasp. - -```wasp -route AboutRoute { path: "/about", to: AboutPage } -``` - -### Fields - -#### `path: string` (required) -URL path of the route. Route path can be parametrised and follows the same conventions as -[React Router](https://reactrouter.com/web/). - -#### `to: page` (required) -Name of the `page` to which the path will lead. -Referenced page must be defined somewhere in `.wasp` file. - -### Example - parametrised URL path -```wasp -route TaskRoute { path: "/task/:id", to: TaskPage } -``` -For details on URL path format check [React Router](https://reactrouter.com/web/) -documentation. - -### Accessing route parameters in a page component - -Since Wasp under the hood generates code with [React Router](https://reactrouter.com/web/), -the same rules apply when accessing URL params in your React components. Here is an example just to get you -started: - -```wasp title="todoApp.wasp" -// ... -route TaskRoute { path: "/task/:id", to: TaskPage } -page TaskPage { - component: import Task from "@client/pages/Task" -} -``` - -```jsx title="pages/Task.js" -import React from 'react' - -const Task = (props) => { - return ( -
    - I am showing a task with id: {props.match.params.id}. -
    - ) -} - -export default Task -``` -### Navigating between routes - -Navigation can be performed from the React code via `` component, also using the functionality of -[React Router](https://reactrouter.com/web/): - -```wasp title="todoApp.wasp" -// ... -route HomeRoute { path: "/home", to: HomePage } -page HomePage { - component: import Home from "@client/pages/Home" -} -``` - -```jsx title="src/client/pages/OtherPage.js" -import React from 'react' -import { Link } from "react-router-dom" - -const OtherPage = (props) => { - return ( - Go to homepage - ) -} -``` - -## Entity - -`entity` declaration represents a database model. -Wasp uses [Prisma](https://www.prisma.io/) to implement database functionality and currently provides only a thin layer above it. - -Each `Entity` declaration corresponds 1-to-1 to Prisma data model and is defined in a following way: - -```wasp -entity Task {=psl - id Int @id @default(autoincrement()) - description String - isDone Boolean @default(false) -psl=} -``` - -### `{=psl ... psl=}: PSL` -Definition of entity fields in *Prisma Schema Language* (PSL). See -[here for intro and examples](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema) -and [here for a more exhaustive language specification](https://github.com/prisma/specs/tree/master/schema). - -### Using Entities - -Entity-system in Wasp is based on [Prisma](http://www.prisma.io), and currently Wasp provides only a thin layer -on top of it. The workflow is as follows: - -1. Wasp developer creates/updates some of the entities in `.wasp` file. -2. Wasp developer runs `wasp db migrate-dev`. -3. Migration data is generated in `migrations/` folder (and should be committed). -4. Wasp developer uses Prisma JS API to work with the database when in Operations. - -#### Using Entities in Operations - -Most of the time in Wasp you will be working with entities in the context of Operations (Queries & Actions), so check their part of docs for more info on how to use entities in Operations. - -#### Using Entities directly - -If needed, you can also interact with entities directly via [Prisma Client(https://www.prisma.io/docs/concepts/components/prisma-client/crud) (although we recommend using them via injected `entities` when in Operations). - -To import Prisma Client in your Wasp server code, do `import prismaClient from '@wasp/dbClient'`. - -## Queries and Actions (aka Operations) - -In Wasp, the client and the server interact with each other through Operations. -Wasp currently supports two kinds of Operations: **Queries** and **Actions**. - -### Query - -Queries are used to fetch data from the server. They do not modify the server's state. - -Queries are implemented in NodeJS and executed within the server's context. -Wasp generates the code that lets you call the Query from anywhere in your code (client or server) using the same interface. -In other words, you won't have to worry about building an HTTP API for the Query, handling the request on the server, or even handling and caching the responses on the client. -Instead, simply focus on the business logic inside your Query and let Wasp take care of the rest! - -To create a Wasp Query, you must: -1. Define the Query's NodeJS implementation -2. Declare the Query in Wasp using the `query` declaration - -After completing these two steps, you'll be able to use the Query from any point in your code. - - -#### Defining the Query's NodeJS implementation -The Query's implementation is a NodeJS function that takes two arguments (it can be an `async` function but doesn't have to). -Since both arguments are positional, you can name the parameters however you want, but we'll stick with `args` and `context`: -1. `args`: An object containing all the arguments (i.e., payload) **passed to the Query by the caller** (e.g., filtering conditions). -Take a look at [the examples of usage](#using-the-query) to see how to pass this object to the Query. -3. `context`: An additional context object **injected into the Query by Wasp**. This object contains user session information, as well as information about entities. The examples here won't use the context for simplicity purposes. You can read more about it in the [section about using entities in queries](#using-entities-in-queries). - -Here's an example of three simple Queries: -```js title="src/server/queries.js" -// our "database" -const tasks = [ - { id: 1, description: "Buy some eggs", isDone: true }, - { id: 2, description: "Make an omelette", isDone: false }, - { id: 3, description: "Eat breakfast", isDone: false } -] - -// You don't need to use the arguments if you don't need them -export const getAllTasks = () => { - return tasks; -} - -// The 'args' object is something sent by the caller (most often from the client) -export const getFilteredTasks = (args) => { - const { isDone } = args; - return tasks.filter(task => task.isDone === isDone) -} - -// Query implementations can be async functions and use await. -export const getTasksWithDelay = async () => { - const result = await sleep(1000) - return tasks -} -``` - -#### Declaring a Query in Wasp -After implementing your Queries in NodeJS, all that's left to do before using them is tell Wasp about it! -You can easily do this with the `query` declaration, which supports the following fields: -- `fn: ServerImport` (required) - The import statement of the Query's NodeJs implementation. -- `entities: [Entity]` (optional) - A list of entities you wish to use inside your Query. -We'll leave this option aside for now. You can read more about it [here](#using-entities-in-queries). - -Wasp Queries and their implementations don't need to (but can) have the same name, so we will keep the names different to avoid confusion. -With that in mind, this is how you might declare the Queries that use the implementations from the previous step: -```wasp title="pages/main.wasp" -// ... - -// Again, it most likely makes sense to name the Wasp Query after -// its implementation. We're changing the name to emphasize the difference. - -query fetchAllTasks { - fn: import { getAllTasks } from "@server/queries.js" -} - -query fetchFilteredTasks { - fn: import { getFilteredTasks } from "@server/queries.js" -} -``` - -After declaring a NodeJS function as a Wasp Query, two crucial things happen: -- Wasp **generates a client-side JavaScript function** that shares its name with the Query (e.g., `fetchFilteredTasks`). -This function takes a single optional argument - an object containing any serializable data you wish to use inside the Query. -Wasp will pass this object to the Query's implementation as its first positional argument (i.e., `args` from the previous step). -Such an abstraction works thanks to an HTTP API route handler Wasp generates on the server, which calls the Query's NodeJS implementation under the hood. -- Wasp **generates a server-side NodeJS function** that shares its name with the Query. This function's interface is identical to the client-side function from the previous point. - -Generating two such functions ensures a uniform calling interface across the entire app (both client and server). - - -#### Using the Query -To use the Query, you can import it from `@wasp` and call it directly. As mentioned, the usage is the same regardless of whether you're on the server or the client: -```javascript -import fetchAllTasks from '@wasp/queries/fetchAllTasks.js' -import fetchFilteredTasks from '@wasp/queries/fetchFilteredTasks.js' - -// ... - -const allTasks = await fetchAllTasks(); -const doneTasks = await fetchFilteredTasks({isDone: true}) -``` - -**NOTE**: Wasp will not stop you from importing a Query's NodeJS implementation from `./queries.js` and calling it directly. However, we advise against this, as you'll lose all the useful features a Wasp Query provides (e.g., entity injection). - -#### The `useQuery` hook -When using Queries on the client, you can make them reactive with the help of the `useQuery` hook. -This hook comes bundled with Wasp and is a thin wrapper around the `useQuery` hook from [_react-query_](https://github.com/tannerlinsley/react-query). - -Wasp's `useQuery` hook accepts three arguments: -- `queryFn` (required): A Wasp query declared in the previous step or, in other words, the client-side query function generated by Wasp based on a `query` declaration. -- `queryFnArgs` (optional): The arguments object (payload) you wish to pass into the Query. The Query's NodeJS implementation will receive this object as its first positional argument. -- `options` (optional): A _react-query_ `options` object. Use this to change - [the default - behaviour](https://react-query.tanstack.com/guides/important-defaults) for - this particular query. If you want to change the global defaults, you can do - so in the [client setup function](#overriding-default-behaviour-for-queries). - -Wasp's `useQuery` hook behaves mostly the same as [_react-query_'s `useQuery` hook](https://react-query.tanstack.com/docs/api#usequery), the only difference being in not having to supply the key (Wasp does this automatically under the hood). - -Here's an example of calling the Queries using the `useQuery` hook: -```jsx -import React from 'react' -import { useQuery } from '@wasp/queries' - -import fetchAllTasks from '@wasp/queries/fetchAllTasks' -import fetchFilteredTasks from '@wasp/queries/fetchFilteredTasks' - -const MainPage = () => { - const { - data: allTasks, - error: error1 - } = useQuery(fetchAllTasks) - - const { - data: doneTasks, - error: error2 - } = useQuery(fetchFilteredTasks, { isDone: true }) - - return ( -
    -

    All Tasks

    - {allTasks ? allTasks.map(task => ) : error1} - -

    Finished Tasks

    - {doneTasks ? doneTasks.map(task => ) : error2} -
    - ) -} - -const Task = ({ description, isDone }) => { - return ( -
    -

    Description: { description }

    -

    Is done: { isDone ? 'Yes' : 'No' }

    -
    - ) -} - - -export default MainPage -``` - -#### Error Handling -For security reasons, all exceptions thrown in the Query's NodeJS implementation are sent to the client as responses with the HTTP status code `500`, with all other details removed. -Hiding error details by default helps against accidentally leaking possibly sensitive information over the network. - -If you do want to pass additional error information to the client, you can construct and throw an appropriate `HttpError` in your NodeJS Query function: -```js title=src/server/queries.js -import HttpError from '@wasp/core/HttpError.js' - -export const getTasks = async (args, context) => { - const statusCode = 403 - const message = 'You can\'t do this!' - const data = { foo: 'bar' } - throw new HttpError(statusCode, message, data) -} -``` - -If the status code is `4xx`, the client will receive a response object with the corresponding `.message` and `.data` fields and rethrow the error (with these fields included). -To prevent information leakage, the server won't forward these fields for any other HTTP status codes. - -#### Using Entities in Queries -In most cases, resources used in Queries will be [Entities](#entity). -To use an Entity in your Query, add it to the query declaration in Wasp: - -```wasp {4,9} title="main.wasp" - -query fetchAllTasks { - fn: import { getAllTasks } from "@server/queries.js", - entities: [Task] -} - -query fetchFilteredTasks { - fn: import { getFilteredTasks } from "@server/queries.js", - entities: [Task] -} -``` - -Wasp will inject the specified Entity into the Query's `context` argument, giving you access to the Entity's Prisma API: -```js title="src/server/queries.js" -export const getAllTasks = async (args, context) => { - return context.entities.Task.findMany({}) -} - -export const getFilteredTasks = async (args, context) => { - return context.entities.Task.findMany({ - where: { isDone: args.isDone } - }) -} -``` - -The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud). - - -### Action - -Actions are very similar to Queries. So similar, in fact, we will only list the differences: -1. They can (and most often should) modify the server's state, while Queries are only allowed to read it. -2. Actions don't need to be reactive so you can call them directly. Still, Wasp does provide a `useAction` React hook for adding extra behavior to the Action (e.g., optimistic updates). -Read more about the [`useAction` hook](#the-useaction-hook) below. -3. `action` declarations in Wasp are mostly identical to `query` declarations. The only difference is in the declaration's name. - -Here's an implementation of a simple Action: - -```js title=src/server/actions.js -export const sayHi = async () => { - console.log('The client said Hi!') -} -``` -Its corresponding declaration in Wasp: - -```wasp title="main.wasp" -// ... - -action sayHi { - fn: import { sayHi } from "@server/actions.js" -} -``` -And an example of how to import and call the declared Action: - -```js -import sayHi from '@wasp/actions/sayHi' - -// ... - -sayHi() -``` - -Here's an example on how you might define a less contrived Action. -```js title=src/server/actions.js -// ... -export const updateTaskIsDone = ({ id, isDone }, context) => { - return context.entities.Task.update({ - where: { id }, - data: { isDone } - }) -} -``` -```wasp title=main.wasp -action updateTaskIsDone { - fn: import { updateTaskIsDone } from "@server/actions.js", - entities: [Task] -} -``` - -And here is how you might use it: -```jsx {4,18} title=src/client/pages/Task.js -import React from 'react' -import { useQuery } from '@wasp/queries' -import fetchTask from '@wasp/queries/fetchTask' -import updateTaskIsDone from '@wasp/actions/updateTaskIsDone' - -const TaskPage = ({ id }) => { - const { data: task } = useQuery(fetchTask, { id }) - - if (!task) { - return

    "Loading"

    - } - - const { description, isDone } = task - return ( -
    -

    Description: {description}

    -

    Is done: {isDone ? 'Yes' : 'No'}

    - -
    - ) -} -``` - -#### The `useAction` hook -When using Actions in components, you can enhance them with the help of the `useAction` hook. This hook comes bundled with Wasp and decorates Wasp Actions. -In other words, the hook returns a function whose API matches the original Action while also doing something extra under the hood (depending on how you configure it). - -The `useAction` hook accepts two arguments: -- `actionFn` (required) - The Wasp Action (i.e., the client-side query function generated by Wasp based on a query declaration) you wish to enhance. -- `actionOptions` (optional) - An object configuring the extra features you want to add to the given Action. While this argument is technically optional, there is no point in using the `useAction` hook without providing it (it would be the same as using the Action directly). The Action options object supports the following fields: - - `optimisticUpdates` (optional) - An array of objects where each object defines an [optimistic update](https://stackoverflow.com/a/33009713) to perform on the query cache. To define an optimistic update, you must specify the following properties: - - `getQuerySpecifier` (required) - A function returning the query specifier (i.e., a value used to address the query you want to update). A query specifier is an array specifying the query function and arguments. For example, to optimistically update the query used with `useQuery(fetchFilteredTasks, {isDone: true }]`, your `getQuerySpecifier` function would have to return the array `[fetchFilteredTasks, { isDone: true}]`. Wasp will forward the argument you pass into the decorated Action to this function (i.e., you can use the properties of the added/change item to address the query). - - `updateQuery` (required) - The function used to perform the optimistic update. It should return the desired state of the cache. Wasp will call it with the following arguments: - - `item` - The argument you pass into the decorated Action. - - `oldData` - The currently cached value for the query identified by the specifier. - -**NOTE:** The `updateQuery` function must be a pure function. It must return the desired cache value identified by the `getQuerySpecifier` function and _must not_ perform any side effects. Also, make sure you only update the query caches affected by your action causing the optimistic update (Wasp cannot yet verify this). Finally, your implementation of the `updateQuery` function should work correctly regardless of the state of `oldData` (e.g., don't rely on array positioning). If you need to do something else during your optimistic update, you can directly use _react-query_'s lower-level API (read more about it [here](#advanced-usage)). - -Here's an example showing how to configure the Action from the previous example to perform an optimistic update: -```jsx {3,9,10,11,12,13,14,15,16,27} title=src/client/pages/Task.js -import React from 'react' -import { useQuery } from '@wasp/queries' -import { useAction } from '@wasp/actions' -import fetchTask from '@wasp/queries/fetchTask' -import updateTaskIsDone from '@wasp/actions/updateTaskIsDone' - -const TaskPage = ({ id }) => { - const { data: task } = useQuery(fetchTask, { id }) - const updateTaskIsDoneOptimistically = useAction(updateTaskIsDone, { - optimisticUpdates: [ - { - getQuerySpecifier: ({ id }) => [fetchTask, { id }], - updateQuery: ({ isDone }, oldData) => ({ ...oldData, isDone }) - } - ] - }) - - if (!task) { - return

    "Loading"

    - } - - const { description, isDone } = task - return ( -
    -

    Description: {description}

    -

    Is done: {isDone ? 'Yes' : 'No'}

    - -
    - Back to main page -
    -
    - ) -} - -export default TaskPage -``` -#### Advanced usage -The `useAction` hook currently only supports specifying optimistic updates. You can expect more features in future versions of Wasp. - -Wasp's optimistic update API is deliberately small and focuses exclusively on updating Query caches (as that's the most common use case). You might need an API that offers more options or a higher level of control. If that's the case, instead of using Wasp's `useAction` hook, you can use _react-query_'s `useMutation` hook and directly work with [their low-level API](https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates). - -If you decide to use _react-query_'s API directly, you will need access to the Query's cache key. Wasp internally uses this key but abstracts it from the programmer. Still, you can easily obtain it by accessing the `queryCacheKey` property on a Query: -```js -import { fetchTasks } from '@wasp/queries' - -const queryKey = fetchTasks.queryCacheKey -``` - -### Cache Invalidation -One of the trickiest parts of managing a web app's state is making sure the data returned by the queries is up to date. -Since Wasp uses _react-query_ for Query management, we must make sure to invalidate Queries (more specifically, their cached results managed by _react-query_) whenever they become stale. - -It's possible to invalidate the caches manually through several mechanisms _react-query_ provides (e.g., refetch, direct invalidation). -However, since manual cache invalidation quickly becomes complex and error-prone, Wasp offers a quicker and a more effective solution to get you started: **automatic Entity-based Query cache invalidation**. -Because Actions can (and most often do) modify the state while Queries read it, Wasp invalidates a Query's cache whenever an Action that uses the same Entity is executed. - -For example, let's assume that Action `createTask` and Query `getTasks` both use Entity `Task`. If `createTask` is executed, `getTasks`'s cached result may no longer be up-to-date. -Wasp will therefore invalidate it, making `getTasks` refetch data from the server, bringing it up to date again. - -In practice, this means that Wasp keeps the queries "fresh" without requiring you to think about cache invalidation. - -On the other hand, this kind of automatic cache invalidation can become wasteful (some updates might not be necessary) and will only work for Entities. If that's an issue, you can use the mechanisms provided by _react-query_ for now, and expect more direct support in Wasp for handling those use cases in a nice, elegant way. - -If you wish to optimistically set cache values after performing an action, you can do so using [optimistic updates](https://stackoverflow.com/a/33009713). Configure them using Wasp's [useAction hook](#the-useaction-hook). This is currently the only manual cache invalidation mechanism Wasps supports natively. For everything else, you can always rely on _react-query_. - -### Prisma Error Helpers -In your Operations, you may wish to handle general Prisma errors with HTTP-friendly responses. We have exposed two helper functions, `isPrismaError`, and `prismaErrorToHttpError`, for this purpose. As of now, we convert two specific Prisma errors (which we will continue to expand), with the rest being `500`. See the [source here](https://github.com/wasp-lang/wasp/blob/main/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js). - -#### `import statement`: -```js -import { isPrismaError, prismaErrorToHttpError } from '@wasp/utils.js' -``` - -##### Example of usage: -```js -try { - await context.entities.Task.create({...}) -} catch (e) { - if (isPrismaError(e)) { - throw prismaErrorToHttpError(e) - } else { - throw e - } -} -``` - -### CRUD operations on top of entities - -:::caution Early preview -This feature is currently in early preview. It doesn't contain all the planned features. - -In the future iterations of Wasp we plan on supporting: -- **authorization** that will allow you to specify which users can perform which operations -- **validation** of input data (e.g. using Zod schema validation) -::: - -For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities. - -#### Which operations are supported? - -If we create CRUD operations for an entity named `Task`, - -```wasp title="main.wasp" -crud Tasks { // crud name here is "Tasks" - entity: Task, - operations: { - getAll: { - isPublic: true, // optional, defaults to false - }, - get: {}, - create: { - overrideFn: import { createTask } from "@server/tasks.js", // optional - }, - update: {}, - }, -} -``` - -Wasp will give you the following default implementations: - -**getAll** - returns all entities - -```js -// ... - -// If the operation is not public, Wasp checks if an authenticated user -// is making the request. - -return Task.findMany() -``` - -**get** - returns one entity by id field - -```js -// ... -// Wasp uses the field marked with `@id` in Prisma schema as the id field. -return Task.findUnique({ where: { id: args.id } }) -``` - -**create** - creates a new entity - -```js -// ... -return Task.create({ data: args.data }) -``` - -**update** - updates an existing entity - -```js -// ... -// Wasp uses the field marked with `@id` in Prisma schema as the id field. -return Task.update({ where: { id: args.id }, data: args.data }) -``` - -**delete** - deletes an existing entity - -```js -// ... -// Wasp uses the field marked with `@id` in Prisma schema as the id field. -return Task.delete({ where: { id: args.id } }) -``` - -:::info Current Limitations -In the default `create` and `update` implementations, we are saving all of the data that the client sends to the server. This is not always desirable, i.e. in the case when the client should not be able to modify all of the data in the entity. - -[In the future](#/docs/guides/crud#future-of-crud-operations-in-wasp), we are planning to add validation of action input, where only the data that the user is allowed to change will be saved. - -For now, the solution is to provide an override function. You can override the default implementation by using the `overrideFn` option and implementing the validation logic yourself. - -::: - -#### CRUD declaration - -The CRUD declaration works on top of an existing entity declaration. It is declared as follows: - -```wasp title="main.wasp" -crud Tasks { // crud name here is "Tasks" - entity: Task, - operations: { - getAll: { - isPublic: true, // optional, defaults to false - }, - get: {}, - create: { - overrideFn: import { createTask } from "@server/tasks.js", // optional - }, - update: {}, - }, -} -``` - -It has the following fields: -- `entity: Entity` - the entity to which the CRUD operations will be applied. -- `operations: { [operationName]: CrudOperationOptions }` - the operations to be generated. The key is the name of the operation, and the value is the operation configuration. - - The possible values for `operationName` are: - - `getAll` - - `get` - - `create` - - `update` - - `delete` - - `CrudOperationOptions` can have the following fields: - - `isPublic: bool` - Whether the operation is public or not. If it is public, no auth is required to access it. If it is not public, it will be available only to authenticated users. Defaults to `false`. - - `overrideFn: ServerImport` - The import statement of the optional override implementation in Node.js. - -#### Defining the overrides - -Like with actions and queries, you can define the implementation in a Javascript/Typescript file. The overrides are functions that take the following arguments: -- `args` - The arguments of the operation i.e. the data that's sent from the client. -- `context` - Context contains the `user` making the request and the `entities` object containing the entity that's being operated on. - -You can also import types for each of the functions you want to override from `@wasp/crud/{crud name}`. The available types are: -- `GetAllQuery` -- `GetQuery` -- `CreateAction` -- `UpdateAction` -- `DeleteAction` - -If you have a CRUD named `Tasks`, you would import the types like this: -```ts -import type { GetAllQuery, GetQuery, CreateAction, UpdateAction, DeleteAction } from '@wasp/crud/Tasks' - -// Each of the types is a generic type, so you can use it like this: -export const getAllOverride: GetAllQuery = async (args, context) => { - // ... -} -``` - -We are showing an example of an override in the [CRUD guide](/docs/guides/crud). - -#### Using the CRUD operations in client code - -On the client, you import the CRUD operations from `@wasp/crud/{crud name}`. The names of the imports are the same as the names of the operations. For example, if you have a CRUD called `Tasks`, you would import the operations like this: - -```jsx title="SomePage.jsx" -import { Tasks } from '@wasp/crud/Tasks' -``` - -You can then access the operations like this: -```jsx title="SomePage.jsx" -const { data } = Tasks.getAll.useQuery() -const { data } = Tasks.get.useQuery({ id: 1 }) -const createAction = Tasks.create.useAction() -const updateAction = Tasks.update.useAction() -const deleteAction = Tasks.delete.useAction() - -// The CRUD operations are using the existing actions and queries -// under the hood, so all the options are available as before. -``` - -Check out the [CRUD guide](/docs/guides/crud) to see how to use the CRUD operations in client code. - -## APIs - -In Wasp, the default client-server interaction mechanism is through [Operations](#queries-and-actions-aka-operations). However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an `api`! Best of all, they should look and feel very familiar. - -### API - -APIs are used to tie a JS function to an HTTP (method, path) pair. They are distinct from Operations and have no client-side helpers (like `useQuery`). - -To create a Wasp API, you must: -1. Define the APIs NodeJS implementation -2. Declare the API in Wasp using the `api` declaration - -After completing these two steps, you'll be able to call the API from client code (via our Axios wrapper), or from the outside world. - -:::note -In order to leverage the benefits of TypeScript and use types in your NodeJS implementation (step 1), you must add your `api` declarations to your `.wasp` file (step 2) _and_ compile the Wasp project. This will enable the Wasp compiler to generate any new types based on your `.wasp`file definitions for use in your implementation files. -::: - -#### Defining the APIs NodeJS implementation -An API should be implemented as a NodeJS function that takes three arguments. -1. `req`: Express Request object -2. `res`: Express Response object -3. `context`: An additional context object **injected into the API by Wasp**. This object contains user session information, as well as information about entities. The examples here won't use the context for simplicity purposes. You can read more about it in the [section about using entities in APIs](#using-entities-in-apis). - -##### Simple API example -```ts title="src/server/apis.ts" -import { FooBar } from '@wasp/apis/types' - -export const fooBar : FooBar = (req, res, context) => { - res.set('Access-Control-Allow-Origin', '*') // Example of modifying headers to override Wasp default CORS middleware. - res.json({ msg: `Hello, ${context.user?.username || "stranger"}!` }) -} -``` - -##### More complicated TypeScript example -Let's say you wanted to create some `GET` route that would take an email address as a param, and provide them the answer to "Life, the Universe and Everything." :) What would this look like in TypeScript? - -```wasp title="main.wasp" -api fooBar { - fn: import { fooBar } from "@server/apis.js", - entities: [Task], - httpRoute: (GET, "/foo/bar/:email") -} -``` - -```ts title="src/server/apis.ts" -import { FooBar } from '@wasp/apis/types' - -export const fooBar: FooBar< -{ email: string }, // params -{ answer: number } // response -> = (req, res, _context) => { - console.log(req.params.email) - res.json({ answer: 42 }) -} -``` - -#### Declaring an API in Wasp -After implementing your APIs in NodeJS, all that's left to do before using them is tell Wasp about it! -You can easily do this with the `api` declaration, which supports the following fields: -- `fn: ServerImport` (required) - The import statement of the APIs NodeJs implementation. -- `httpRoute: (HttpMethod, string)` (required) - The HTTP (method, path) pair, where the method can be one of: - - `ALL`, `GET`, `POST`, `PUT` or `DELETE` - - and path is an Express path `string`. -- `entities: [Entity]` (optional) - A list of entities you wish to use inside your API. -We'll leave this option aside for now. You can read more about it [here](#using-entities-in-apis). -- `auth: bool` (optional) - If auth is enabled, this will default to `true` and provide a `context.user` object. If you do not wish to attempt to parse the JWT in the Authorization Header, you may set this to `false`. -- `middlewareConfigFn: ServerImport` (optional) - The import statement to an Express middleware config function for this API. See [the guide here](/docs/guides/middleware-customization#2-customize-api-specific-middleware). - -Wasp APIs and their implementations don't need to (but can) have the same name. With that in mind, this is how you might declare the API that uses the implementations from the previous step: -```wasp title="pages/main.wasp" -// ... - -api fooBar { - fn: import { fooBar } from "@server/apis.js", - httpRoute: (GET, "/foo/bar") -} -``` - -#### Using the API -To use the API externally, you simply call the endpoint using the method and path you used. For example, if your app is running at `https://example.com` then from the above you could issue a `GET` to `https://example/com/foo/callback` (in your browser, Postman, `curl`, another web service, etc.). - -To use the API from your client, including with auth support, you can import the Axios wrapper from `@wasp/api` and invoke a call. For example: -```ts -import React, { useEffect } from 'react' -import api from '@wasp/api' - -async function fetchCustomRoute() { - const res = await api.get('/foo/bar') - console.log(res.data) -} - -export const Foo = () => { - useEffect(() => { - fetchCustomRoute() - }, []); - - return ( - <> - // ... - - ) -} -``` - -#### Using Entities in APIs -In many cases, resources used in APIs will be [Entities](#entity). -To use an Entity in your API, add it to the `api` declaration in Wasp: - -```wasp {3} title="main.wasp" -api fooBar { - fn: import { fooBar } from "@server/apis.js", - entities: [Task], - httpRoute: (GET, "/foo/bar") -} -``` - -Wasp will inject the specified Entity into the APIs `context` argument, giving you access to the Entity's Prisma API: -```ts title="src/server/apis.ts" -import { FooBar } from '@wasp/apis/types' - -export const fooBar : FooBar = (req, res, context) => { - res.json({ count: await context.entities.Task.count() }) -} - -``` - -The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud). - -### apiNamespace - -An `apiNamespace` is a simple declaration used to apply some `middlewareConfigFn` to all APIs under some specific path. For example: - -```wasp title="main.wasp" -apiNamespace fooBar { - middlewareConfigFn: import { fooBarNamespaceMiddlewareFn } from "@server/apis.js", - path: "/foo/bar" -} -``` - -For more information about middleware configuration, please see: [Middleware Configuration](/docs/guides/middleware-customization) - -## Jobs - -If you have server tasks that you do not want to handle as part of the normal request-response cycle, Wasp allows you to make that function a `job` and it will gain some "superpowers." Jobs will: - * persist between server restarts - * can be retried if they fail - * can be delayed until the future - * can have a recurring schedule! - -Some examples where you may want to use a `job` on the server include sending an email, making an HTTP request to some external API, or doing some nightly calculations. - -### Job Executors - -Job executors handle the scheduling, monitoring, and execution of our jobs. - -Wasp allows you to choose which job executor will be used to execute a specific job that you define, which affects some of the finer details of how jobs will behave and how they can be further configured. Each job executor has its pros and cons, which we will explain in more detail below, so you can pick the one that best suits your needs. - -Currently, Wasp supports only one type of job executor, which is `PgBoss`, but in the future, it will likely support more. - -#### pg-boss - -We have selected [pg-boss](https://github.com/timgit/pg-boss/) as our first job executor to handle the low-volume, basic job queue workloads many web applications have. By using PostgreSQL (and [SKIP LOCKED](https://www.2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5/)) as its storage and synchronization mechanism, it allows us to provide many job queue pros without any additional infrastructure or complex management. - -:::info -Keep in mind that pg-boss jobs run alongside your other server-side code, so they are not appropriate for CPU-heavy workloads. Additionally, some care is required if you modify scheduled jobs. Please see pg-boss details below for more information. - -
    - pg-boss details - - pg-boss provides many useful features, which can be found [here](https://github.com/timgit/pg-boss/blob/8.4.2/README.md). - - When you add pg-boss to a Wasp project, it will automatically add a new schema to your database called `pgboss` with some internal tracking tables, including `job` and `schedule`. pg-boss tables have a `name` column in most tables that will correspond to your `job` identifier. Additionally, these tables maintain arguments, states, return values, retry information, start and expiration times, and other metadata required by pg-boss. - - If you need to customize the creation of the pg-boss instance, you can set an environment variable called `PG_BOSS_NEW_OPTIONS` to a stringified JSON object containing [these initialization parameters](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#newoptions). **NOTE**: Setting this overwrites all Wasp defaults, so you must include database connection information as well. - - ##### pg-boss considerations - - Wasp starts pg-boss alongside your web server's application, where both are simultaneously operational. This means that jobs running via pg-boss and the rest of the server logic (like Operations) share the CPU, therefore you should avoid running CPU-intensive tasks via jobs. - - Wasp does not (yet) support independent, horizontal scaling of pg-boss-only applications, nor starting them as separate workers/processes/threads. - - The job name/identifier in your `.wasp` file is the same name that will be used in the `name` column of pg-boss tables. If you change a name that had a `schedule` associated with it, pg-boss will continue scheduling those jobs but they will have no handlers associated, and will thus become stale and expire. To resolve this, you can remove the applicable row from the `schedule` table in the `pgboss` schema of your database. - - If you remove a `schedule` from a job, you will need to do the above as well. - - If you wish to deploy to Heroku, you need to set an additional environment variable called `PG_BOSS_NEW_OPTIONS` to `{"connectionString":"","ssl":{"rejectUnauthorized":false}}`. This is because pg-boss uses the `pg` extension, which does not seem to connect to Heroku over SSL by default, which Heroku requires. Additionally, Heroku uses a self-signed cert, so we must handle that as well. -- https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js - -
    -::: - -### Basic job definition and usage - -To declare a `job` in Wasp, simply add a declaration with a reference to an `async` function, like the following: - -```wasp title="main.wasp" -job mySpecialJob { - executor: PgBoss, - perform: { - fn: import { foo } from "@server/workers/bar.js" - } -} -``` - -Then, in your [Operations](/docs/language/features#queries-and-actions-aka-operations) or [setupFn](/docs/language/features#setupfn-serverimport-optional) (or any other NodeJS code), you can submit work to be done: -```js -import { mySpecialJob } from '@wasp/jobs/mySpecialJob.js' - -const submittedJob = await mySpecialJob.submit({ job: "args" }) -console.log(await submittedJob.pgBoss.details()) - -// Or, if you'd prefer it to execute in the future, just add a .delay(). -// It takes a number of seconds, Date, or ISO date string. -await mySpecialJob.delay(10).submit({ job: "args" }) -``` - -And that is it! Your job will be executed by the job executor (pg-boss, in this case) as if you called `foo({ job: "args" })`. - -Note that in our example, `foo` takes an argument, but this does not always have to be the case. It all depends on how you've implemented your worker function. - -### Recurring jobs - -If you have work that needs to be done on some recurring basis, you can add a `schedule` to your job declaration: - -```wasp {6-9} title="main.wasp" -job mySpecialJob { - executor: PgBoss, - perform: { - fn: import { foo } from "@server/workers/bar.js" - }, - schedule: { - cron: "0 * * * *", - args: {=json { "job": "args" } json=} // optional - } -} -``` - -In this example, you do _not_ need to invoke anything in JavaScript. You can imagine `foo({ job: "args" })` getting automatically scheduled and invoked for you every hour. - -### Fully specified example -Both `perform` and `schedule` accept `executorOptions`, which we pass directly to the named job executor when you submit jobs. In this example, the scheduled job will have a `retryLimit` set to 0, as `schedule` overrides any similar property from `perform`. Lastly, we add an entity to pass in via the context argument to `perform.fn`. - -```wasp -job mySpecialJob { - executor: PgBoss, - perform: { - fn: import { foo } from "@server/workers/bar.js", - executorOptions: { - pgBoss: {=json { "retryLimit": 1 } json=} - } - }, - schedule: { - cron: "*/5 * * * *", - args: {=json { "foo": "bar" } json=}, - executorOptions: { - pgBoss: {=json { "retryLimit": 0 } json=} - } - }, - entities: [Task], -} -``` - -### Fields - -#### `executor: JobExecutor` (required) -`PgBoss` is currently our only job executor, and is recommended for low-volume production use cases. It requires your `app.db.system` to be `PostgreSQL`. - -#### `perform: dict` (required) - - - ##### `fn: ServerImport` (required) - An `async` JavaScript function of work to be performed. Since Wasp executes jobs on the server, you must import it from `@server`. The function receives a first argument which may be passed when the job is called, as well as the context containing any declared entities as the second (this is passed automatically by Wasp). Here is a sample signature: - - ```js - export async function foo(args, context) { - // Can reference context.entities.Task, for example. - } - ``` - - - ##### `executorOptions: dict` (optional) - Executor-specific default options to use when submitting jobs. These are passed directly through and you should consult the documentation for the job executor. These can be overridden during invocation with `submit()` or in a `schedule`. - - - ##### `pgBoss: JSON` (optional) - See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#sendname-data-options). - -#### `schedule: dict` (optional) - - - ##### `cron: string` (required) - A 5-placeholder format cron expression string. See rationale for minute-level precision [here](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#scheduling). - - _If you need help building cron expressions, Check out_ [Crontab guru](https://crontab.guru/#0_*_*_*_*). - - - ##### `args: JSON` (optional) - The arguments to pass to the `perform.fn` function when invoked. - - - ##### `executorOptions: dict` (optional) - Executor-specific options to use when submitting jobs. These are passed directly through and you should consult the documentation for the job executor. The `perform.executorOptions` are the default options, and `schedule.executorOptions` can override/extend those. - - - ##### `pgBoss: JSON` (optional) - See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#sendname-data-options). - -#### `entities: [Entity]` (optional) -A list of entities you wish to use inside your Job (similar to Queries and Actions). - -### JavaScript API - -#### Invocation -##### `import` - -```js -import { mySpecialJob } from '@wasp/jobs/mySpecialJob.js' -``` - -##### `submit(jobArgs, executorOptions)` -- ###### `jobArgs: JSON` (optional) -- ###### `executorOptions: JSON` (optional) - -Submits a `job` to be executed by an executor, optionally passing in a JSON job argument your job handler function will receive, and executor-specific submit options. - -```js -const submittedJob = await mySpecialJob.submit({ job: "args" }) -``` - -##### `delay(startAfter)` (optional) -- ###### `startAfter: int | string | Date` (required) - -Delaying the invocation of the job handler. The delay can be one of: -- Integer: number of seconds to delay. [Default 0] -- String: ISO date string to run at. -- Date: Date to run at. - -```js -const submittedJob = await mySpecialJob.delay(10).submit({ job: "args" }, { "retryLimit": 2 }) -``` - -#### Tracking -The return value of `submit()` is an instance of `SubmittedJob`, which minimally contains: -- `jobId`: A getter returning the UUID String ID for the job in that executor. -- `jobName`: A getter returning the name of the job you used in your `.wasp` file. -- `executorName`: A getter returning a Symbol of the name of the job executor. - - For pg-boss, you can import a Symbol from: `import { PG_BOSS_EXECUTOR_NAME } from '@wasp/jobs/core/pgBoss/pgBossJob.js'` if you wish to compare against `executorName`. - -There will also be namespaced, job executor-specific objects. - -- For pg-boss, you may access: `pgBoss` - - **NOTE**: no arguments are necessary, as we already applied the `jobId` in the available functions. - - `details()`: pg-boss specific job detail information. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#getjobbyidid) - - `cancel()`: attempts to cancel a job. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#cancelid) - - `resume()`: attempts to resume a canceled job. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#resumeid) - -## Dependencies - -You can specify additional npm dependencies via `dependencies` field in `app` declaration, in following way: - -```wasp -app MyApp { - title: "My app", - // ... - dependencies: [ - ("redux", "^4.0.5"), - ("react-redux", "^7.1.3") - ] -} -``` - -You will need to re-run `wasp start` after adding a dependency for Wasp to pick it up. - -**NOTE**: In current implementation of Wasp, if Wasp is already internally using certain npm dependency with certain version specified, you are not allowed to define that same npm dependency yourself while specifying different version. -If you do that, you will get an error message telling you which exact version you have to use for that dependency. -This means Wasp dictates exact versions of certain packages, so for example you can't choose version of React you want to use. -In the future, we will add support for picking any version you like, but we have not implemented that yet. Check [issue #59](https://github.com/wasp-lang/wasp/issues/59) to check out the progress or contribute. - - -## Authentication & Authorization - -Wasp provides authentication and authorization support out-of-the-box. Enabling it for your app is optional and can be done by configuring the `auth` field of the `app` declaration: - -```wasp -app MyApp { - title: "My app", - //... - auth: { - userEntity: User, - externalAuthEntity: SocialLogin, - methods: { - usernameAndPassword: {}, // use this or email, not both - email: {}, // use this or usernameAndPassword, not both - google: {}, - gitHub: {}, - }, - onAuthFailedRedirectTo: "/someRoute" - } -} - -//... -``` - -`app.auth` is a dictionary with following fields: - -#### `userEntity: entity` (required) -Entity which represents the user. - -#### `externalAuthEntity: entity` (optional) -Entity which associates a user with some external authentication provider. We currently offer support for Google and GitHub. See the sections on [Social Login Providers](#social-login-providers-oauth-20) for more info. - -#### `methods: dict` (required) -List of authentication methods that Wasp app supports. Currently supported methods are: -* `usernameAndPassword`: authentication with a username and password. See [here](#username-and-password) for more. -* `email`: authentication with a email and password. See [here](#email-authentication) for more. -* `google`: authentication via Google accounts. See [here](#social-login-providers-oauth-20) for more. -* `gitHub`: authentication via GitHub accounts. See [here](#social-login-providers-oauth-20) for more. - -#### `onAuthFailedRedirectTo: String` (required) -Path where an unauthenticated user will be redirected to if they try to access a private page (which is declared by setting `authRequired: true` for a specific page). -Check out this [section of our Todo app tutorial](/docs/tutorial/auth#update-the-main-page-to-require-auth) to see an example of usage. - -#### `onAuthSucceededRedirectTo: String` (optional) -Path where a successfully authenticated user will be sent upon successful login/signup. -Default value is "/". - -:::note -Automatic redirect on successful login only works when using the Wasp provided [`Signup` and `Login` forms](#high-level-api) -::: - -### Username and Password - -`usernameAndPassword` authentication method makes it possible to signup/login into the app by using a username and password. -This method requires that `userEntity` specified in `auth` contains `username: string` and `password: string` fields: - -```wasp -app MyApp { - title: "My app", - //... - - auth: { - userEntity: User, - methods: { - usernameAndPassword: {}, - }, - onAuthFailedRedirectTo: "/someRoute" - } -} - -// Wasp requires the userEntity to have at least the following fields -entity User {=psl - id Int @id @default(autoincrement()) - username String @unique - password String -psl=} -``` - -We provide basic validations out of the box, which you can customize as shown below. Default validations are: -- `username`: non-empty -- `password`: non-empty, at least 8 characters, and contains a number - -Note that `username`s are stored in a case-sensitive manner. - -#### High-level API - -The quickest way to get started is by using the following API generated by Wasp: -- Signup and Login forms at `@wasp/auth/forms/Signup` and `@wasp/auth/forms/Login` routes - - For styling, these default authentication components have form classes associated for both login (`login-form`) and signup (`signup-form`). Additionally, they both share a common class (`auth-form`). -- `logout` function -- `useAuth()` React hook -**NOTE:** If the signup is successful, the Signup form will automatically log in the user. - -Check our [Todo app tutorial](/docs/tutorial/auth) to see how it works. See below for detailed specification of each of these methods. - -#### Lower-level API - -If you require more control in your authentication flow, you can achieve that in the following ways: -- If you don't want to use already generated Signup and Login forms and want to create your own, you can use `signup` and `login` function by invoking them from the client. -- If you want to execute custom code on the server during sign up, create your own sign up action which invokes Prisma client as `context.entities.[USER_ENTITY].create()` function, along with your custom code. - -The code of your custom sign-up action would look like this (your user entity being `User` in this instance): -```js title="src/server/auth/signup.js" -export const signUp = async (args, context) => { - // Your custom code before sign-up. - // ... - - const newUser = context.entities.User.create({ - data: { - username: args.username, - password: args.password // password hashed automatically by Wasp! 🐝 - } - }) - - // Your custom code after sign-up. - // ... - return newUser -} -``` - -:::info -You don't need to worry about hashing the password yourself! Even when you are using Prisma's client directly and calling `create()` with a plain-text password, Wasp's middleware takes care of hashing it before storing it in the database. An additional middleware also performs field validation. -::: - -##### Customizing user entity validations - -To disable/enable default validations, or add your own, you can modify your custom signUp function like so: -```js -const newUser = context.entities.User.create({ - data: { - username: args.username, - password: args.password // password hashed automatically by Wasp! 🐝 - }, - _waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true - _waspCustomValidations: [ - { - validates: 'password', - message: 'password must contain an uppercase letter', - validator: password => /[A-Z]/.test(password) - }, - ] -}) -``` - -:::info -Validations always run on `create()`, but only when the field mentioned in `validates` is present for `update()`. The validation process stops on the first `validator` to return false. If enabled, default validations run first and validate basic properties of both the `'username'` or `'password'` fields. -::: - -#### Specification - -#### `login()` -An action for logging in the user. -```js -login(username, password) -``` -:::info -When using the exposed `login()` function, make sure to implement your own redirect on successful login logic -::: - -#### `username: string` -Username of the user logging in. - -#### `password: string` -Password of the user logging in. - -#### `import statement`: -```js -import login from '@wasp/auth/login' -``` -Login is a regular action and can be used directly from the frontend. - - -#### `signup()` -An action for signing up the user. This action does not log in the user, you still need to call `login()`. - -```js -signup(userFields) -``` -#### `userFields: object` -Auth-related fields (either `username` or `email` and `password`) of the user entity which was declared in `auth`. - -:::info -Wasp only stores the auth-related fields of the user entity. Adding extra fields to `userFields` will not have any effect. - -If you need to add extra fields to the user entity, we suggest doing it in a separate step after the user logs in for the first time. -::: - -#### `import statement`: -```js -import signup from '@wasp/auth/signup' -``` -Signup is a regular action and can be used directly from the frontend. - -#### `logout()` -An action for logging out the user. -```js -logout() -``` - -#### `import statement`: -```js -import logout from '@wasp/auth/logout' -``` - -##### Example of usage: -```jsx -import logout from '@wasp/auth/logout' - -const SignOut = () => { - return ( - - ) -} -``` - -#### Updating a user's password -If you need to update user's password, you can do it safely via Prisma client, e.g. within an action: -```js -export const updatePassword = async (args, context) => { - return context.entities.User.update({ - where: { id: args.userId }, - data: { - password: 'New pwd which will be hashed automatically!' - } - }) -} -``` -You don't need to worry about hashing the password yourself - if you have an `auth` declaration -in your `.wasp` file, Wasp already set a middleware on Prisma that makes sure whenever password -is created or updated on the user entity, it is also hashed before it is stored to the database. - -### Email authentication - -:::info -We have written a step-by-step guide on how to set up the e-mail authentication with Wasp's included Auth UI. - -Read more in the [email authentication guide](/docs/guides/email-auth). -::: - -:::warning -If a user signs up with Google or Github (and you set it up to save their social provider e-mail info on the `User` entity), they'll be able to reset their password and login with e-mail and password. - -If a user signs up with the e-mail and password and then tries to login with a social provider (Google or Github), they won't be able to do that. - -In the future, we will lift this limitation and enable smarter merging of accounts. -::: - -`email` authentication method makes it possible to signup/login into the app by using an e-mail and a password. - -```wasp title="main.wasp" -app MyApp { - title: "My app", - // ... - - auth: { - userEntity: User, - methods: { - email: { - // we'll deal with `email` below - }, - }, - onAuthFailedRedirectTo: "/someRoute" - }, - // ... -} - -// Wasp requires the userEntity to have at least the following fields -entity User {=psl - id Int @id @default(autoincrement()) - email String? @unique - password String? - isEmailVerified Boolean @default(false) - emailVerificationSentAt DateTime? - passwordResetSentAt DateTime? -psl=} -``` - -This method requires that `userEntity` specified in `auth` contains: - -- optional `email` field of type `String` -- optional `password` field of type `String` -- `isEmailVerified` field of type `Boolean` with a default value of `false` -- optional `emailVerificationSentAt` field of type `DateTime` -- optional `passwordResetSentAt` field of type `DateTime` - -#### Fields in the `email` dict - -```wasp title="main.wasp" -app MyApp { - title: "My app", - // ... - - auth: { - userEntity: User, - methods: { - email: { - fromField: { - name: "My App", - email: "hello@itsme.com" - }, - emailVerification: { - clientRoute: EmailVerificationRoute, - getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js", - }, - passwordReset: { - clientRoute: PasswordResetRoute, - getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", - }, - allowUnverifiedLogin: false, - }, - }, - onAuthFailedRedirectTo: "/someRoute" - }, - // ... -} -``` - -##### `fromField: EmailFromField` (required) -`fromField` is a dict that specifies the name and e-mail address of the sender of the e-mails sent by Wasp. It is required to be defined. The object has the following fields: -- `name`: name of the sender (optional) -- `email`: e-mail address of the sender - -##### `emailVerification: EmailVerificationConfig` (required) -`emailVerification` is a dict that specifies the e-mail verification process. It is required to be defined. - -The object has the following fields: -- `clientRoute: Route`: a route that is used for the user to verify their e-mail address. (required) - -Client route should handle the process of taking a token from the URL and sending it to the server to verify the e-mail address. You can use our `verifyEmail` action for that. - -```js title="src/pages/EmailVerificationPage.jsx" -import { verifyEmail } from '@wasp/auth/email/actions'; -... -await verifyEmail({ token }); -``` - -Read on how to do it the easiest way with Auth UI in the [email authentication guide](/docs/guides/email-auth). - -- `getEmailContentFn: ServerImport`: a function that returns the content of the e-mail that is sent to the user. (optional) - -Defining `getEmailContentFn` can be done by defining a Javscript or Typescript file in the `server` directory. - -```ts title="server/email.ts" -import { GetVerificationEmailContentFn } from '@wasp/types' - -export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ - verificationLink, -}) => ({ - subject: 'Verify your email', - text: `Click the link below to verify your email: ${verificationLink}`, - html: ` -

    Click the link below to verify your email

    - Verify email - `, -}) -``` - -##### `passwordReset: PasswordResetConfig` (required) -`passwordReset` is a dict that specifies the password reset process. It is required to be defined. The object has the following fields: -- `clientRoute: Route`: a route that is used for the user to reset their password. (required) - -Client route should handle the process of taking a token from the URL and a new password from the user and sending it to the server. You can use our `requestPasswordReset` and `resetPassword` actions to do that. - -```js title="src/pages/ForgotPasswordPage.jsx" -import { requestPasswordReset } from '@wasp/auth/email/actions'; -... -await requestPasswordReset({ email }); -``` - -```js title="src/pages/PasswordResetPage.jsx" -import { resetPassword } from '@wasp/auth/email/actions'; -... -await resetPassword({ password, token }) -``` - -##### `allowUnverifiedLogin: bool`: a boolean that specifies whether the user can login without verifying their e-mail address. (optional) - -It defaults to `false`. If `allowUnverifiedLogin` is set to `true`, the user can login without verifying their e-mail address, otherwise users will receive a `401` error when trying to login without verifying their e-mail address. - - -Read on how to do it the easiest way with Auth UI in the [email authentication guide](/docs/guides/email-auth). - -- `getEmailContentFn: ServerImport`: a function that returns the content of the e-mail that is sent to the user. (optional) - -Defining `getEmailContentFn` is done by defining a function that looks like this: - -```ts title="server/email.ts" -import { GetPasswordResetEmailContentFn } from '@wasp/types' - -export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ - passwordResetLink, -}) => ({ - subject: 'Password reset', - text: `Click the link below to reset your password: ${passwordResetLink}`, - html: ` -

    Click the link below to reset your password

    - Reset password - `, -}) -``` - -#### Email sender for email authentication - -We require that you define an `emailSender`, so that Wasp knows how to send e-mails. Read more about that [here](#email-sender). - -#### Validations - -We provide basic validations out of the box. The validations are: -- `email`: non-empty, valid e-mail address -- `password`: non-empty, at least 8 characters, and contains a number - -Note that `email`s are stored in a case-insensitive manner. - -:::info -You don't need to worry about hashing the password yourself! Even when you are using Prisma's client directly and calling `create()` with a plain-text password, Wasp's middleware takes care of hashing it before storing it in the database. An additional middleware also performs field validation. -::: - - - -### Social Login Providers (OAuth 2.0) -Wasp allows you to easily add social login providers to your app. - -The following is a list of links to guides that will help you get started with the currently supported providers: -- [GitHub](/docs/integrations/github) -- [Google](/docs/integrations/google) - -When using Social Login Providers, Wasp gives you the following options: -- Default settings to get you started quickly -- UI Helpers to make it easy to add social login buttons and actions -- Override settings to customize the behavior of the providers - -#### Default Settings - - - - - -```wasp - auth: { - userEntity: User, - externalAuthEntity: SocialLogin, - methods: { - google: {}, - }, - } -``` - -

    Add google: {} to your auth.methods dictionary to use it with default settings

    -

    By default, Wasp expects you to set two environment variables in order to use Google authentication:

    -
      -
    • GOOGLE_CLIENT_ID
    • -
    • GOOGLE_CLIENT_SECRET
    • -
    -

    These can be obtained in your Google Cloud Console project dashboard. See here for a detailed guide.

    - -
    - - -```wasp - auth: { - userEntity: User, - externalAuthEntity: SocialLogin, - methods: { - gitHub: {}, - }, - } -``` - -

    Add gitHub: {} to your auth.methods dictionary to use it with default settings

    -

    By default, Wasp expects you to set two environment variables in order to use GitHub authentication:

    -
      -
    • GITHUB_CLIENT_ID
    • -
    • GITHUB_CLIENT_SECRET
    • -
    -

    These can be obtained in your GitHub project dashboard. See here for a detailed guide.

    - -
    -
    - -When a user signs in for the first time, if the `userEntity` has `username` and/or `password` fields Wasp assigns generated values to those fields by default (e.g. `username: nice-blue-horse-14357` and a strong random `password`). This is a historical coupling between auth methods that will be removed over time. If you'd like to change this behavior, these values can be overridden as described below. - -:::tip Overriding Defaults -It is also possible to [override the default](features#overrides-for-social-login-providers) login behaviors that Wasp provides for you. This allows you to create custom setups, such as allowing Users to define a username rather than the default random username assigned by Wasp on initial Login. -::: - -#### `externalAuthEntity` -Anytime an authentication method is used that relies on an external authorization provider, for example, Google, we require an `externalAuthEntity` specified in `auth`, in addition to the `userEntity`, that contains the following configuration: - -```wasp {4,14} -//... - auth: { - userEntity: User, - externalAuthEntity: SocialLogin, -//... - -entity User {=psl - id Int @id @default(autoincrement()) - //... - externalAuthAssociations SocialLogin[] -psl=} - -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=} -``` -:::note -the same `externalAuthEntity` can be used across different social login providers (e.g., both GitHub and Google can use the same entity). -::: -#### UI helpers - -Wasp provides sign-in buttons, logos and URLs for your login page: - -```jsx -... -import { SignInButton as GoogleSignInButton, signInUrl as googleSignInUrl, logoUrl as googleLogoUrl } from '@wasp/auth/helpers/Google' -import { SignInButton as GitHubSignInButton, signInUrl as gitHubSignInUrl, logoUrl as gitHubLogoUrl } from '@wasp/auth/helpers/GitHub' - -const Login = () => { - return ( - <> - ... - - - - {/* or */} - Sign in with Google - Sign in with GitHub - - ) -} - -export default Login -``` - -If you need more customization than what the buttons provide, you can create your own custom components using the `signInUrl`s. - -#### Overrides - -When a user signs in for the first time, Wasp will create a new User account and link it to the chosen Auth Provider account for future logins. If the `userEntity` contains a `username` field it will default to a random dictionary phrase that does not exist in the database, such as `nice-blue-horse-27160`. This is a historical coupling between auth methods that will be removed over time. - -If you would like to allow the user to select their own username, or some other sign up flow, you could add a boolean property to your `User` entity indicating the account setup is incomplete. You can then check this user's property on the client with the [`useAuth()`](#useauth) hook and redirect them when appropriate - - e.g. check on homepage if `user.isAuthSetup === false`, redirect them to `EditUserDetailsPage` where they can edit the `username` property. - -Alternatively, you could add a `displayName` property to your User entity and assign it using the details of their provider account. Below is an example of how to do this by using: - - the `getUserFieldsFn` function to configure the user's `username` or `displayName` from their provider account - -We also show you how to customize the configuration of the Provider's settings using: - - the `configFn` function - -```wasp title=main.wasp {9,10,13,14,26} -app Example { - //... - - auth: { - userEntity: User, - externalAuthEntity: SocialLogin, - methods: { - google: { - configFn: import { config } from "@server/auth/google.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" - }, - gitHub: { - configFn: import { config } from "@server/auth/github.js", - getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" - } - }, - - //... - } -} - -entity User {=psl - id Int @id @default(autoincrement()) - username String @unique - password String - displayName String? - externalAuthAssociations SocialLogin[] -psl=} - -//... - -``` - - -#### `configFn` - -This function should return an object with the following shape: - - - -```js title=src/server/auth/google.js -export function config() { - // ... - return { - clientID, // look up from env or elsewhere, - clientSecret, // look up from env or elsewhere, - scope: ['profile'] // must include at least 'profile' for Google - } -} - -// ... -``` - -

    Here is a link to the default implementations as a reference

    -
    - - -```js title=src/server/auth/github.js -export function config() { - // ... - return { - clientID, // look up from env or elsewhere, - clientSecret, // look up from env or elsewhere, - scope: [] // default is an empty array for GitHub - } -} - -// ... -``` - -

    Here is a link to the default implementations as a reference

    -
    -
    - -#### `getUserFieldsFn` - -This function should return the user fields to use when creating a new user upon their first time logging in with a Social Auth Provider. The context contains a User entity for DB access, and the args are what the OAuth provider responds with. Here is how you could generate a username based on the Google display name. In your model, you could choose to add more attributes and set additional information. - ```js title=src/server/auth/google.js - import { generateAvailableUsername } from '@wasp/core/auth.js' - - // ... - - export async function getUserFields(_context, args) { - const username = await generateAvailableUsername(args.profile.displayName.split(' '), { separator: '.' }) - return { username } - } - ``` - - Or you could set the optional `displayName` property on the `User` entity instead: - ```js title=src/server/auth/google.js - import { generateAvailableDictionaryUsername, generateAvailableUsername } from '@wasp/core/auth.js' - - // ... - - export async function getUserFields(_context, args) { - const username = await generateAvailableDictionaryUsername() - const displayName = await generateAvailableUsername(args.profile.displayName.split(' '), { separator: '.' }) - return { username, displayName } - } - ``` - - `generateAvailableUsername` takes an array of Strings and an optional separator and generates a string ending with a random number that is not yet in the database. For example, the above could produce something like "Jim.Smith.3984" for a Google user Jim Smith. - - `generateAvailableDictionaryUsername` generates a random dictionary phrase that is not yet in the database. For example, `nice-blue-horse-27160`. - - -### Validation Error Handling -When creating, updating, or deleting entities, you may wish to handle validation errors. We have exposed a class called `AuthError` for this purpose. This could also be combined with [Prisma Error Helpers](/docs/language/features#prisma-error-helpers). - -#### `import statement`: -```js -import AuthError from '@wasp/core/AuthError.js' -``` - -##### Example of usage: -```js -try { - await context.entities.User.update(...) -} catch (e) { - if (e instanceof AuthError) { - throw new HttpError(422, 'Validation failed', { message: e.message }) - } else { - throw e - } -} -``` - -## Accessing the currently logged in user -When authentication is enabled in a Wasp app, we need a way to tell whether a user is logged in and access its data. -With that, we can further implement access control and decide which content is private and which public. - -#### On the client -On the client, Wasp provides a React hook you can use in functional components - `useAuth`. -This hook is actually a thin wrapper over Wasp's [`useQuery` hook](http://localhost:3002/docs/language/features#the-usequery-hook) and returns data in the same format. - -### `useAuth()` -#### `import statement`: -```js -import useAuth from '@wasp/auth/useAuth' -``` - -##### Example of usage: -```js title="src/client/pages/MainPage.js" -import React from 'react' - -import { Link } from 'react-router-dom' -import useAuth from '@wasp/auth/useAuth' -import logout from '@wasp/auth/logout' -import Todo from '../Todo' -import '../Main.css' - -const Main = () => { - const { data: user } = useAuth() - - if (!user) { - return ( - - Please login or sign up. - - ) - } else { - return ( - <> - - - < /> - ) - } -} - -export default Main -``` - -#### On the server - -### `context.user` - -When authentication is enabled, all operations (actions and queries) will have access to the `user` through the `context` argument. `context.user` will contain all the fields from the user entity except for the password. - -##### Example of usage: -```js title="src/server/actions.js" -import HttpError from '@wasp/core/HttpError.js' - -export const createTask = async (task, context) => { - if (!context.user) { - throw new HttpError(403) - } - - const Task = context.entities.Task - return Task.create({ - data: { - description: task.description, - user: { - connect: { id: context.user.id } - } - } - }) -} -``` -In order to implement access control, each operation is responsible for checking `context.user` and -acting accordingly - e.g. if `context.user` is `undefined` and the operation is private then user -should be denied access to it. - -## Client configuration - -You can configure the client using the `client` field inside the `app` -declaration, - -```wasp -app MyApp { - title: "My app", - // ... - client: { - rootComponent: import Root from "@client/Root.jsx", - setupFn: import mySetupFunction from "@client/myClientSetupCode.js" - } -} -``` - -`app.client` is a dictionary with the following fields: - -#### `rootComponent: ClientImport` (optional) - -`rootComponent` defines the root component of your client application. It is -expected to be a React component, and Wasp will use it to wrap your entire app. -It must render its children, which are the actual pages of your application. - -You can use it to define a common layout for your application: - -```jsx title="src/client/Root.jsx" -export default async function Root({ children }) { - return ( -
    -
    -

    My App

    -
    - {children} -
    -

    My App footer

    -
    -
    - ) -} -``` - -You can use it to set up various providers that your application needs: - -```jsx title="src/client/Root.jsx" -import store from './store' -import { Provider } from 'react-redux' - -export default async function Root({ children }) { - return ( - - {children} - - ) -} -``` - -As long as you render the children, you can do whatever you want in your root -component. Here's an example of a root component both sets up a provider and -renders a custom layout: - -```jsx title="src/client/Root.jsx" -import store from './store' -import { Provider } from 'react-redux' - -export default function Root({ children }) { - return ( - - - {children} - - - ) -} - -function Layout({ children }) { - return ( -
    -
    -

    My App

    -
    - {children} -
    -

    My App footer

    -
    -
    - ) -} -``` - - -#### `setupFn: ClientImport` (optional) - -`setupFn` declares a JavaScript function that Wasp executes on the client -before everything else. It is expected to be asynchronous, and -Wasp will await its completion before rendering the page. The function takes no -arguments, and its return value is ignored. - -You can use this function to perform any custom setup (e.g., setting up -client-side periodic jobs). - -Here's a dummy example of such a function: - -```js title="src/client/myClientSetupCode.js" -export default async function mySetupFunction() { - let count = 1; - setInterval( - () => console.log(`You have been online for ${count++} hours.`), - 1000 * 60 * 60, - ) -} -``` - -##### Overriding default behaviour for Queries -As mentioned, our `useQuery` hook uses _react-query_'s hook of the same name. -Since _react-query_ comes configured with aggressive but sane default options, -you most likely won't have to change those defaults for all Queries (you can -change them for a single Query using the `options` object, as described -[here](#the-usequery-hook)). - -Still, if you do need the global defaults, you can do so inside client setup -function. Wasp exposes a `configureQueryClient` hook that lets you configure -_react-query_'s `QueryClient` object: - - -```js title="src/client/myClientSetupCode.js" -import { configureQueryClient } from '@wasp/queryClient' - -export default async function mySetupFunction() { - // ... some setup - configureQueryClient({ - defaultOptions: { - queries: { - staleTime: Infinity, - } - } - }) - // ... some more setup -} -``` - -Make sure to pass in an object expected by the `QueryClient`'s constructor, as -explained in -[_react-query_'s docs](https://tanstack.com/query/v4/docs/react/reference/QueryClient). - -## Public static files on the client - -If you wish to override the default `favicon.ico` file or expose any other static files to the client, you can do so by placing them in the `public` directory in the `src/client` folder. - -The contents of this directory will be copied to the `dist/public` directory during the build process. This makes these files available at the root of the domain. For example, if you have a file `favicon.ico` in the `public` directory, it will be available at `https://example.com/favicon.ico`. - -For example, doing this: -``` -src -└── client - ├── public - │ └── favicon.ico - └── ... -``` -will result in the following directory structure in the `build` folder: -``` -build -└── public - └── favicon.ico -``` - -:::warning Usage in client code -You **can't import these files** from your client code. They are only exposed at the root of the domain, e.g. `https://example.com/favicon.ico`. -::: - -## Server configuration - -Via `server` field of `app` declaration, you can configure the behavior of the Node.js server (one that is executing wasp operations). - -```wasp -app MyApp { - title: "My app", - // ... - server: { - setupFn: import mySetupFunction from "@server/myServerSetupCode.js" - } -} -``` - -`app.server` is a dictionary with the following fields: - -#### `middlewareConfigFn: ServerImport` (optional) - -The import statement to an Express middleware config function. This is a global modification affecting all operations and APIs. See [the guide here](/docs/guides/middleware-customization#1-customize-global-middleware). - -#### `setupFn: ServerImport` (optional) - -`setupFn` declares a JS function that will be executed on server start. This function is expected to be async and will be awaited before the server starts accepting any requests. - -It allows you to do any custom setup, e.g. setting up additional database/websockets or starting cron/scheduled jobs. - -The `setupFn` function receives the `express.Application` and the `http.Server` instances as part of its context. They can be useful for setting up any custom server routes or for example, setting up `socket.io`. -```ts -export type ServerSetupFn = (context: ServerSetupFnContext) => Promise - -export type ServerSetupFnContext = { - app: Application, // === express.Application - server: Server, // === http.Server -} -``` - -As an example, adding a custom route would look something like: -```ts title="src/server/myServerSetupCode.ts" -import { ServerSetupFn, Application } from '@wasp/types' - -const mySetupFunction: ServerSetupFn = async ({ app }) => { - addCustomRoute(app) -} - -function addCustomRoute(app: Application) { - app.get('/customRoute', (_req, res) => { - res.send('I am a custom route') - }) -} -``` - -In case you want to store some values for later use, or to be accessed by the Operations, recommended way is to store those in variables in the same module/file where you defined the javascript setup function and then expose additional functions for reading those values, which you can then import directly from Operations and use. This effectively turns your module into a singleton whose construction is performed on server start. - -Dummy example of such function and its usage: - -```js title="src/server/myServerSetupCode.js" -let someResource = undefined - -const mySetupFunction = async () => { - // Let's pretend functions setUpSomeResource and startSomeCronJob - // are implemented below or imported from another file. - someResource = await setUpSomeResource() - startSomeCronJob() -} - -export const getSomeResource = () => someResource - -export default mySetupFunction -``` - -```js title="src/server/queries.js" -import { getSomeResource } from './myServerSetupCode.js' - -... - -export const someQuery = async (args, context) => { - const someResource = getSomeResource() - return queryDataFromSomeResource(args, someResource) -} -``` - - -## .env - -Your project will likely be using environment variables for configuration, typically to define connection to the database, API keys for external services and similar. - -When in production, you will typically define environment variables through mechanisms provided by your hosting provider. - -However, when in development, you might also need to supply certain environment variables, and to avoid doing it "manually", Wasp supports `.env` (dotenv) files where you can define environment variables that will be used during development (they will not be used during production). - -Since environmental variables are usually different for server-side and client apps, in Wasp root directly you can create two files, `.env.server` for server-side of your Wasp project, and `.env.client` for client side (or web app) of Wasp project. - - -`.env.server` and `.env.client` files have to be defined in the root of your Wasp project. - -`.env.server` and `.env.client` files should not be committed to the version control - we already ignore it by default in the .gitignore file we generate when you create a new Wasp project via `wasp new` cli command. - -Variables are defined in `.env.server` or `.env.client` files in the form of `NAME=VALUE`, for example: -``` -DATABASE_URL=postgresql://localhost:5432 -MY_VAR=somevalue -``` - -Any env vars defined in the `.env.server` / `.env.client` files will be forwarded to the server-side / web app of your Wasp project and therefore accessible in your javascript code via `process.env`, for example: -```js -console.log(process.env.DATABASE_URL) -``` - -## Database configuration - -Via `db` field of `app` declaration, you can configure the database used by Wasp. - -```wasp -app MyApp { - title: "My app", - // ... - db: { - system: PostgreSQL, - seeds: [ - import devSeed from "@server/dbSeeds.js" - ], - prisma: { - clientPreviewFeatures: ["extendedWhereUnique"] - } - } -} -``` - -`app.db` is a dictionary with following fields: - -#### - `system: DbSystem` (Optional) -The database system Wasp will use. It can be either `PostgreSQL` or `SQLite`. -If not defined, or even if whole `db` field is not present, default value is `SQLite`. -If you add/remove/modify `db` field, run `wasp db migrate-dev` to apply the changes. - -#### - `seeds: [ServerImport]` (Optional) -Defines seed functions that you can use via `wasp db seed` to seed your database with initial data. -Check out [Seeding](#seeding) section for more details. - -#### - `prisma: [PrismaOptions]` (Optional) -Additional configuration for Prisma. -It currently only supports a single field: - - `clientPreviewFeatures : string` - allows you to define [Prisma client preview features](https://www.prisma.io/docs/concepts/components/preview-features/client-preview-features). - - -### SQLite -Default database is `SQLite`, since it is great for getting started with a new project (needs no configuring), but it can be used only in development - once you want to deploy Wasp to production you will need to switch to `PostgreSQL` and stick with it. -Check below for more details on how to migrate from SQLite to PostgreSQL. - -### PostgreSQL -When using `PostgreSQL` as your database (`app: { db: { system: PostgreSQL } }`), you will need to make sure you have a postgres database running during development (when running `wasp start` or doing `wasp db ...` commands). - -### Using Wasp provided dev database - -Wasp provides `wasp start db` command that starts the default dev db for you. - -Your Wasp app will automatically connect to it once you have it running via `wasp start db`, no additional configuration is needed. This command relies on Docker being installed on your machine. - -### Connecting to existing database - -If instead of using `wasp start db` you would rather spin up your own dev database or connect to some external database, you will need to provide Wasp with `DATABASE_URL` environment variable that Wasp will use to connect to it. - -The easiest way to provide the needed `DATABASE_URL` environment variable is by adding it to the [.env.server](https://wasp-lang.dev/docs/language/features#env) file in the root dir of your Wasp project (if that file doesn't yet exist, create it). - -You can also set it per command by doing `DATABASE_URL= wasp ...` -> this can be useful if you want to run specific `wasp` command on a specific database. -Example: you could do `DATABASE_URL= wasp db seed myProdSeed` to seed data for a fresh staging or production database. - -### Migrating from SQLite to PostgreSQL -To run Wasp app in production, you will need to switch from `SQLite` to `PostgreSQL`. - -1. Set `app.db.system` to `PostgreSQL`. -3. Delete old migrations, since they are SQLite migrations and can't be used with PostgreSQL: `rm -r migrations/`. -3. Run `wasp start db` to start your new db running (or check instructions above if you prefer using your own db). Leave it running, since we need it for the next step. -4. In a different terminal, run `wasp db migrate-dev` to apply new changes and create new, initial migration. -5. That is it, you are all done! - -### Seeding - -**Database seeding** is a term for populating database with some initial data. - -Seeding is most commonly used for two following scenarios: - 1. To put development database into a state convenient for testing / playing with it. - 2. To initialize dev/staging/prod database with some essential data needed for it to be useful, - for example default currencies in a Currency table. - -#### Writing a seed function - -Wasp enables you to define multiple **seed functions** via `app.db.seeds`: - -```wasp -app MyApp { - // ... - db: { - // ... - seeds: [ - import { devSeedSimple } from "@server/dbSeeds.js", - import { prodSeed } from "@server/dbSeeds.js" - ] - } -} -``` - -Each seed function is expected to be an async function that takes one argument, `prismaClient`, which is a [Prisma Client](https://www.prisma.io/docs/concepts/components/prisma-client/crud) instance that you can use to interact with the database. -This is the same instance of Prisma Client that Wasp uses internally, so you e.g. get password hashing for free. - -Since a seed function is part of the server-side code, it can also import other server-side code, so you can and will normally want to import and use Actions to perform the seeding. - -Example of a seed function that imports an Action (+ a helper function to create a user): - -```js -import { createTask } from './actions.js' - -export const devSeedSimple = async (prismaClient) => { - const user = await createUser(prismaClient, { - username: "RiuTheDog", - password: "bark1234" - }) - - await createTask( - { description: "Chase the cat" }, - { user, entities: { Task: prismaClient.task } } - ) -} - -async function createUser (prismaClient, data) { - const { password, ...newUser } = await prismaClient.user.create({ data }) - return newUser -} -``` - -#### Running seed functions - - - `wasp db seed`: If you have just one seed function, it will run it. If you have multiple, it will interactively ask you to choose one to run. - - - `wasp db seed `: It will run the seed function with the specified name, where the name is the identifier you used in its `import` expression in the `app.db.seeds` list. Example: `wasp db seed devSeedSimple`. - -:::tip - Often you will want to call `wasp db seed` right after you ran `wasp db reset`: first you empty your database, then you fill it with some initial data. -::: - - -## Email sender - -#### `provider: EmailProvider` (required) - -We support multiple different providers for sending e-mails: `SMTP`, `SendGrid` and `Mailgun`. - -### SMTP - -SMTP e-mail sender uses your SMTP server to send e-mails. - -Read [our guide](/docs/advanced/email#using-the-smtp-provider) for setting up SMTP for more details. - - -### SendGrid - -SendGrid is a popular service for sending e-mails that provides both API and SMTP methods of sending e-mails. We use their official SDK for sending e-mails. - -Check out [our guide](/docs/advanced/email#using-the-sendgrid-provider) for setting up Sendgrid for more details. - -### Mailgun - -Mailgun is a popular service for sending e-mails that provides both API and SMTP methods of sending e-mails. We use their official SDK for sending e-mails. - -Check out [our guide](/docs/advanced/email#using-the-mailgun-provider) for setting up Mailgun for more details. - -#### `defaultSender: EmailFromField` (optional) - -You can optionally provide a default sender info that will be used when you don't provide it explicitly when sending an e-mail. - -```wasp -app MyApp { - title: "My app", - // ... - emailSender: { - provider: SMTP, - defaultFrom: { - name: "Hello", - email: "hello@itsme.com" - }, - }, -} -``` - -After you set up the email sender, you can use it in your code to send e-mails. For example, you can send an e-mail when a user signs up, or when a user resets their password. - -### Sending e-mails - - - -To send an e-mail, you can use the `emailSender` that is provided by the `@wasp/email` module. - -```ts title="src/actions/sendEmail.js" -import { emailSender } from '@wasp/email/index.js' - -// In some action handler... -const info = await emailSender.send({ - to: 'user@domain.com', - subject: 'Saying hello', - text: 'Hello world', - html: 'Hello world' -}) -``` diff --git a/web/docs/project/client-config.md b/web/docs/project/client-config.md index e7ca198e05..9b9070ef9c 100644 --- a/web/docs/project/client-config.md +++ b/web/docs/project/client-config.md @@ -211,7 +211,7 @@ export default async function mySetupFunction(): Promise { ### Overriding Default Behaviour for Queries :::info -You can change the options for a **single** Query using the `options` object, as described [here](/docs/data-model/operations/queries#the-usequery-hook-1). +You can change the options for a **single** Query using the `options` object, as described [here](../data-model/operations/queries#the-usequery-hook-1). ::: Wasp's `useQuery` hook uses `react-query`'s `useQuery` hook under the hood. Since `react-query` comes configured with aggressive but sane default options, you most likely won't have to change those defaults for all Queries. diff --git a/web/docs/project/css-frameworks.md b/web/docs/project/css-frameworks.md index f5ca4ecdff..a1f9972298 100644 --- a/web/docs/project/css-frameworks.md +++ b/web/docs/project/css-frameworks.md @@ -85,19 +85,15 @@ Make sure to use the `.cjs` extension for these config files, if you name them w ### Adding Tailwind Plugins -To add Tailwind plugins, add it to [dependencies](/docs/project/dependencies) in your `main.wasp` file and to the plugins list in your `tailwind.config.cjs` file: +To add Tailwind plugins, install them as npm development [dependencies](../project/dependencies) and add them to the plugins list in your `tailwind.config.cjs` file: -```wasp title="./main.wasp" {4-5} -app todoApp { - // ... - dependencies: [ - ("@tailwindcss/forms", "^0.5.3"), - ("@tailwindcss/typographjy", "^0.5.7"), - ], - // ... -} +```shell +npm install -D @tailwindcss/forms +npm install -D @tailwindcss/typography ``` +and also + ```js title="./tailwind.config.cjs" {5-6} /** @type {import('tailwindcss').Config} */ module.exports = { diff --git a/web/docs/project/customizing-app.md b/web/docs/project/customizing-app.md index fe3bffea4d..859d652d54 100644 --- a/web/docs/project/customizing-app.md +++ b/web/docs/project/customizing-app.md @@ -2,7 +2,7 @@ title: Customizing the App --- -import { Required } from '@site/src/components/Required'; +import { Required } from '@site/src/components/Tag'; Each Wasp project can have only one `app` type declaration. It is used to configure your app and its components. @@ -76,9 +76,6 @@ app todoApp { db: { // ... }, - dependencies: [ - // ... - ], emailSender: { // ... }, @@ -113,28 +110,24 @@ The rest of the fields are covered in dedicated sections of the docs: - `auth: dict` - Authentication configuration. Read more in the [authentication section](/docs/auth/overview) of the docs. + Authentication configuration. Read more in the [authentication section](../auth/overview) of the docs. - `client: dict` - Configuration for the client side of your app. Read more in the [client configuration section](/docs/project/client-config) of the docs. + Configuration for the client side of your app. Read more in the [client configuration section](../project/client-config) of the docs. - `server: dict` - Configuration for the server side of your app. Read more in the [server configuration section](/docs/project/server-config) of the docs. + Configuration for the server side of your app. Read more in the [server configuration section](../project/server-config) of the docs. - `db: dict` - Database configuration. Read more in the [database configuration section](/docs/data-model/backends) of the docs. - -- `dependencies: [(string, string)]` - - List of npm dependencies for your app. Read more in the [dependencies section](/docs/project/dependencies) of the docs. + Database configuration. Read more in the [database configuration section](../data-model/backends) of the docs. - `emailSender: dict` - Email sender configuration. Read more in the [email sending section](/docs/advanced/email) of the docs. + Email sender configuration. Read more in the [email sending section](../advanced/email) of the docs. - `webSocket: dict` - WebSocket configuration. Read more in the [WebSocket section](/docs/advanced/web-sockets) of the docs. + WebSocket configuration. Read more in the [WebSocket section](../advanced/web-sockets) of the docs. diff --git a/web/docs/project/dependencies.md b/web/docs/project/dependencies.md index d6c179373b..a3a0d8593a 100644 --- a/web/docs/project/dependencies.md +++ b/web/docs/project/dependencies.md @@ -2,31 +2,12 @@ title: Dependencies --- -Specifying npm dependencies in Wasp project is done via the `dependencies` field in the `app` declaration, in the following way: - -```wasp -app MyApp { - title: "My app", - // ... - dependencies: [ - ("redux", "^4.0.5"), - ("react-redux", "^7.1.3") - ] -} -``` - -You will need to re-run `wasp start` after adding a dependency for Wasp to pick it up. - -The quickest way to find out the latest version of a package is to run: -```shell -npm view version -``` +Specifying npm dependencies in Wasp project is done in a typical way for JS projects: via [package.json](https://docs.npmjs.com/cli/configuring-npm/package-json) file at the top level of the project, via the `dependencies` (or `devDependencies`) field. :::note Using Packages that are Already Used by Wasp Internally In the current implementation of Wasp, if Wasp is already internally using a certain npm dependency with a certain version specified, you are not allowed to define that same npm dependency yourself while specifying _a different version_. If you do that, you will get an error message telling you which exact version you have to use for that dependency. This means Wasp _dictates exact versions of certain packages_, so for example you can't choose version of React you want to use. - -We are currently working on a restructuring that will solve this and some other quirks that the current dependency system has: check [issue #734](https://github.com/wasp-lang/wasp/issues/734) to follow our progress. +We are currently working on a restructuring that will solve this and some other quirks: check [issue #734](https://github.com/wasp-lang/wasp/issues/734) to follow our progress. ::: diff --git a/web/docs/project/env-vars.md b/web/docs/project/env-vars.md index d4157b239d..775c75cc39 100644 --- a/web/docs/project/env-vars.md +++ b/web/docs/project/env-vars.md @@ -131,4 +131,4 @@ The way you provide env vars to your Wasp project in production depends on where flyctl secrets set SOME_VAR_NAME=somevalue ``` -You can read a lot more details in the [deployment section](/docs/advanced/deployment/manually) of the docs. We go into detail on how to define env vars for each deployment option. +You can read a lot more details in the [deployment section](../advanced/deployment/manually) of the docs. We go into detail on how to define env vars for each deployment option. diff --git a/web/docs/project/server-config.md b/web/docs/project/server-config.md index 5fe5e60f2a..10800d1f59 100644 --- a/web/docs/project/server-config.md +++ b/web/docs/project/server-config.md @@ -90,7 +90,7 @@ function addCustomRoute(app: Application) { ### Storing Some Values for Later Use -In case you want to store some values for later use, or to be accessed by the [Operations](/docs/data-model/operations/overview) you do that in the `setupFn` function. +In case you want to store some values for later use, or to be accessed by the [Operations](../data-model/operations/overview) you do that in the `setupFn` function. Dummy example of such function and its usage: @@ -247,4 +247,4 @@ app MyApp { - #### `middlewareConfigFn: ServerImport` - The import statement to an Express middleware config function. This is a global modification affecting all operations and APIs. See more in the [configuring middleware section](/docs/advanced/middleware-config#1-customize-global-middleware). + The import statement to an Express middleware config function. This is a global modification affecting all operations and APIs. See more in the [configuring middleware section](../advanced/middleware-config#1-customize-global-middleware). diff --git a/web/docs/project/testing.md b/web/docs/project/testing.md index b64f2ed0bc..5816cd1c23 100644 --- a/web/docs/project/testing.md +++ b/web/docs/project/testing.md @@ -69,7 +69,7 @@ Wasp provides several functions to help you write React tests: const { mockQuery, mockApi } = mockServer(); ``` - - `mockQuery`: Takes a Wasp [query](/docs/data-model/operations/queries) to mock and the JSON data it should return. + - `mockQuery`: Takes a Wasp [query](../data-model/operations/queries) to mock and the JSON data it should return. ```js import getTasks from "@wasp/queries/getTasks"; @@ -81,7 +81,7 @@ Wasp provides several functions to help you write React tests: - Behind the scenes, Wasp uses [`msw`](https://npmjs.com/package/msw) to create a server request handle that responds with the specified data. - Mock are cleared between each test. - - `mockApi`: Similar to `mockQuery`, but for [APIs](/docs/advanced/apis). Instead of a Wasp query, it takes a route containing an HTTP method and a path. + - `mockApi`: Similar to `mockQuery`, but for [APIs](../advanced/apis). Instead of a Wasp query, it takes a route containing an HTTP method and a path. ```js import { HttpMethod } from "@wasp/types"; diff --git a/web/docs/tutorial/01-create.md b/web/docs/tutorial/01-create.md index a735e16678..96d31de7ea 100644 --- a/web/docs/tutorial/01-create.md +++ b/web/docs/tutorial/01-create.md @@ -5,7 +5,7 @@ title: 1. Creating a New Project import useBaseUrl from '@docusaurus/useBaseUrl'; :::info -You'll need to have the latest version of Wasp installed locally to follow this tutorial. If you haven't installed it yet, check out the [QuickStart](/docs/quick-start) guide! +You'll need to have the latest version of Wasp installed locally to follow this tutorial. If you haven't installed it yet, check out the [QuickStart](../quick-start) guide! ::: In this section, we'll guide you through the process of creating a simple Todo app with Wasp. In the process, we'll take you through the most important and useful features of Wasp. diff --git a/web/docs/tutorial/02-project-structure.md b/web/docs/tutorial/02-project-structure.md index 0160d5cebd..168e6ac85c 100644 --- a/web/docs/tutorial/02-project-structure.md +++ b/web/docs/tutorial/02-project-structure.md @@ -35,7 +35,7 @@ By _your code_, we mean the _"the code you write"_, as opposed to the code gener Many of the other files (`tsconfig.json`, `vite-env.d.ts`, etc.) are used by your IDE to improve your development experience with tools like autocompletion, intellisense, and error reporting. The file `vite.config.ts` is used to configure [Vite](https://vitejs.dev/guide/), Wasp's build tool of choice. -We won't be configuring Vite in this tutorial, so you can safely ignore the file. Still, if you ever end up wanting more control over Vite, you'll find everything you need to know in [custom Vite config docs](/docs/project/custom-vite-config.md). +We won't be configuring Vite in this tutorial, so you can safely ignore the file. Still, if you ever end up wanting more control over Vite, you'll find everything you need to know in [custom Vite config docs](../project/custom-vite-config.md). :::note TypeScript Support Wasp supports TypeScript out of the box, but you are free to choose between or mix JavaScript and TypeScript as you see fit. diff --git a/web/docs/tutorial/03-pages.md b/web/docs/tutorial/03-pages.md index ca0f9944ac..7c8052f10f 100644 --- a/web/docs/tutorial/03-pages.md +++ b/web/docs/tutorial/03-pages.md @@ -142,7 +142,7 @@ Now you can visit `/hello/johnny` and see "Here's johnny!" :::tip Type-safe links -Since you are using Typescript, you can benefit from using Wasp's type-safe `Link` component and the `routes` object. Check out the [type-safe links docs](/docs/advanced/links) for more details. +Since you are using Typescript, you can benefit from using Wasp's type-safe `Link` component and the `routes` object. Check out the [type-safe links docs](../advanced/links) for more details. ::: diff --git a/web/docs/tutorial/04-entities.md b/web/docs/tutorial/04-entities.md index 42ee9d68c2..2565c9d3d4 100644 --- a/web/docs/tutorial/04-entities.md +++ b/web/docs/tutorial/04-entities.md @@ -21,7 +21,7 @@ psl=} :::note Wasp uses [Prisma](https://www.prisma.io) as a way to talk to the database. You define entities by defining [Prisma models](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/data-model/) using the Prisma Schema Language (PSL) between the `{=psl psl=}` tags. -Read more in the [Entities](/docs/data-model/entities) section of the docs. +Read more in the [Entities](../data-model/entities) section of the docs. ::: To update the database schema to include this entity, stop the `wasp start` process, if its running, and run: diff --git a/web/docs/tutorial/05-queries.md b/web/docs/tutorial/05-queries.md index 748de193f9..2056a71e8b 100644 --- a/web/docs/tutorial/05-queries.md +++ b/web/docs/tutorial/05-queries.md @@ -5,7 +5,7 @@ title: 5. Querying the Database import useBaseUrl from '@docusaurus/useBaseUrl'; import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers'; -We want to know which tasks we need to do, so let's list them! The primary way of interacting with entities in Wasp is by using [queries and actions](/docs/data-model/operations/overview), collectively known as _operations_. +We want to know which tasks we need to do, so let's list them! The primary way of interacting with entities in Wasp is by using [queries and actions](../data-model/operations/overview), collectively known as _operations_. Queries are used to read an entity, while actions are used to create, modify, and delete entities. Since we want to list the tasks, we'll want to use a query. @@ -225,14 +225,14 @@ Most of this code is regular React, the only exception being the two< - `import getTasks from '@wasp/queries/getTasks'` - Imports the client-side query function. -- `import { useQuery } from '@wasp/queries'` - Imports Wasp's [useQuery](/docs/data-model/operations/queries#the-usequery-hook-1) React hook, which is based on [react-query](https://github.com/tannerlinsley/react-query)'s hook with the same name. +- `import { useQuery } from '@wasp/queries'` - Imports Wasp's [useQuery](../data-model/operations/queries#the-usequery-hook-1) React hook, which is based on [react-query](https://github.com/tannerlinsley/react-query)'s hook with the same name. - `import getTasks from '@wasp/queries/getTasks'` - Imports the client-side query function. -- `import { useQuery } from '@wasp/queries'` - Imports Wasp's [useQuery](/docs/data-model/operations/queries#the-usequery-hook-1) React hook, which is based on [react-query](https://github.com/tannerlinsley/react-query)'s hook with the same name. +- `import { useQuery } from '@wasp/queries'` - Imports Wasp's [useQuery](../data-model/operations/queries#the-usequery-hook-1) React hook, which is based on [react-query](https://github.com/tannerlinsley/react-query)'s hook with the same name. - `import { Task } from '@wasp/entities'` - The type for the task entity we defined in `main.wasp`. Notice how you don't need to annotate the type of the query's return value: Wasp uses the types you defined while implementing the query for the generated client-side function. This is **full-stack type safety**: the types on the client always match the types on the server. diff --git a/web/docs/tutorial/07-auth.md b/web/docs/tutorial/07-auth.md index 8e7ebde46c..8be57baaea 100644 --- a/web/docs/tutorial/07-auth.md +++ b/web/docs/tutorial/07-auth.md @@ -19,27 +19,19 @@ First, we'll create a Todo list for what needs to be done (luckily we have an ap ## Creating a User Entity -Since Wasp manages authentication, it expects certain fields to exist on the `User` entity. Specifically, it expects a unique `username` field and a `password` field, both of which should be strings. +Since Wasp manages authentication, it will create [the auth related entities](../auth/entities) for us in the background, that we don't have to worry about. However, we still need to add the `User` entity that will help us keep track of which user owns which tasks. ```wasp title="main.wasp" // ... entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String psl=} ``` -As we talked about earlier, we have to remember to update the database schema: - -```sh -wasp db migrate-dev -``` - ## Adding Auth to the Project -Next, we want to tell Wasp that we want to use full-stack [authentication](/docs/auth/overview) in our app: +Next, we want to tell Wasp that we want to use full-stack [authentication](../auth/overview) in our app: ```wasp {7-16} title="main.wasp" app TodoApp { @@ -63,15 +55,21 @@ app TodoApp { // ... ``` +As we talked about earlier, we have to remember to update the database schema: + +```sh +wasp db migrate-dev +``` + By doing this, Wasp will create: -- [Auth UI](/docs/auth/ui) with login and signup forms. +- [Auth UI](../auth/ui) with login and signup forms. - A `logout()` action. - A React hook `useAuth()`. - `context.user` for use in Queries and Actions. :::info -Wasp also supports authentication using [Google](/docs/auth/social-auth/google), [GitHub](/docs/auth/social-auth/github), and [email](/docs/auth/email), with more on the way! +Wasp also supports authentication using [Google](../auth/social-auth/google), [GitHub](../auth/social-auth/github), and [email](../auth/email), with more on the way! ::: ## Adding Login and Signup Pages @@ -216,7 +214,7 @@ export default SignupPage :::tip Type-safe links -Since you are using Typescript, you can benefit from using Wasp's type-safe `Link` component and the `routes` object. Check out the [type-safe links docs](/docs/advanced/links) for more details. +Since you are using Typescript, you can benefit from using Wasp's type-safe `Link` component and the `routes` object. Check out the [type-safe links docs](../advanced/links) for more details. ::: @@ -250,9 +248,9 @@ const MainPage = ({ user }) => { ```tsx {3} title="src/client/MainPage.tsx" -import { User } from '@wasp/entities' +import { User as AuthenticatedUser } from '@wasp/auth/types' -const MainPage = ({ user }: { user: User }) => { +const MainPage = ({ user }: { user: AuthenticatedUser }) => { // Do something with the user } ``` @@ -271,25 +269,31 @@ wasp db studio ``` Database demonstration - password hashing -We see there is a user and that its password is already hashed 🤯 + +You'll notice that we now have a `User` entity in the database alongside the `Task` entity. However, you will notice that if you try logging in as different users and creating some tasks, all users share the same tasks. That's because we haven't yet updated the queries and actions to have per-user tasks. Let's do that next. + + +You might notice some extra Prisma models like `Auth`, `AuthIdentity` and `Session` that Wasp created for us. You don't need to care about these right now, but if you are curious, you can read more about them [here](../auth/entities). + + + ## Defining a User-Task Relation First, let's define a one-to-many relation between users and tasks (check the [Prisma docs on relations](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/relations)): -```wasp {7,14-15} title="main.wasp" +```wasp title="main.wasp" // ... entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String + // highlight-next-line tasks Task[] psl=} @@ -297,7 +301,9 @@ entity Task {=psl id Int @id @default(autoincrement()) description String isDone Boolean @default(false) + // highlight-next-line user User? @relation(fields: [userId], references: [id]) + // highlight-next-line userId Int? psl=} @@ -515,8 +521,8 @@ You should be ready to learn about more complicated features and go more in-dept Looking for inspiration? -- Get a jump start on your next project with [Starter Templates](/docs/project/starter-templates) -- Make a real-time app with [Web Sockets](/docs/advanced/web-sockets) +- Get a jump start on your next project with [Starter Templates](../project/starter-templates) +- Make a real-time app with [Web Sockets](../advanced/web-sockets) :::note If you notice that some of the features you'd like to have are missing, or have any other kind of feedback, please write to us on [Discord](https://discord.gg/rzdnErX) or create an issue on [Github](https://github.com/wasp-lang/wasp), so we can learn which features to add/improve next 🙏 diff --git a/web/docs/typescript.md b/web/docs/typescript.md deleted file mode 100644 index 6f04d7277b..0000000000 --- a/web/docs/typescript.md +++ /dev/null @@ -1,521 +0,0 @@ ---- -title: TypeScript Support ---- - -import OldDocsNote from '@site/docs/OldDocsNote' - -# Using Wasp with TypeScript - - - -TypeScript is a programming language that brings static type analysis to JavaScript. It is a superset of JavaScript (i.e., all valid JavaScript programs are valid TypeScript programs) and compiles to JavaScript before running. TypeScript's type system detects common errors at build time (reducing the chance of runtime errors in production) and enables type-based auto-completion in IDEs. - -This document assumes you are familiar with TypeScript and primarily focuses on how to use it with Wasp. To learn more about TypeScript itself, we recommend reading [the official docs](https://www.typescriptlang.org/docs/). - -The document also assumes a basic understanding of core Wasp features (e.g., Queries, Actions, Entities). You can read more about these features in [our feature docs](/docs/language/features). - -Besides allowing you to write your code in TypeScript, Wasp also supports: - -- Importing and using Wasp Entity types (on both the server and the client). -- Automatic full-stack type support for Queries and Actions - frontend types are automatically inferred from backend definitions. -- Type-safe generic hooks (`useQuery` and `useAction`) with the accompanying type inference. -- Type-safe optimistic update definitions. - -We'll dig into the details of each feature in the following sections. But first, let's see how you can introduce TypeScript to an existing Wasp project. - -:::info -To get the best IDE experience, make sure to leave `wasp start` running in the background. Wasp will track the working directory and ensure the generated code/types are up to date with your changes. - -Your editor may sometimes report type and import errors even while `wasp start` is running. This happens when the TypeScript Language Server gets out of sync with the current code. If you're using VS Code, you can manually restart the language server by opening the command palette and selecting _"TypeScript: Restart TS Server."_ -::: - -## Migrating your project to TypeScript - -Wasp supports TypeScript out of the box! - -Our scaffolding already includes TypeScript, so migrating your project to TypeScript is as simple as changing file extensions and using the language. This approach allows you to gradually migrate your project on a file-by-file basis. - -### Example - -Let's first assume your Wasp file contains the following definitions: - -```wasp title=main.wasp -entity Task {=psl - id Int @id @default(autoincrement()) - description String - isDone Boolean @default(false) -psl=} - -query getTaskInfo { - fn: import { getTaskInfo } from "@server/queries.js", - entities: [Task] -} -``` - -Let's now assume that your `queries.js` file looks something like this: - -```javascript title="src/server/queries.js" -import HttpError from "@wasp/core/HttpError.js" - -function getInfoMessage(task) { - const isDoneText = task.isDone ? "is done" : "is not done" - return `Task '${task.description}' is ${isDoneText}.` -} - -export const getTaskInfo = async ({ id }, context) => { - const Task = context.entities.Task - const task = await Task.findUnique({ where: { id } }) - if (!task) { - throw new HttpError(404) - } - return getInfoMessage(task) -} -``` -To migrate this file to TypeScript, all you have to do is: - -1. Change the filename from `queries.js` to `queries.ts`. -2. Write some types. - -Let's start by only providing a basic `getInfoMessage` function. We'll see how to properly type the rest of the file in the following sections. - -```typescript title=src/server/queries.ts -import HttpError from "@wasp/core/HttpError.js" - -// highlight-next-line -function getInfoMessage(task: { - isDone: boolean - description: string -}): string { - const isDoneText = task.isDone ? "is done" : "is not done" - return `Task '${task.description}' is ${isDoneText}.` -} - -export const getTaskInfo = async ({ id }, context) => { - const Task = context.entities.Task - const task = await Task.findUnique({ where: { id } }) - if (!task) { - throw new HttpError(404) - } - return getInfoMessage(task) -} -``` - -You don't need to change anything inside the `.wasp` file. -:::caution - - - -Even when you use TypeScript, and your file is called `queries.ts`, you still need to import it using the `.js` extension: - -```wasp -query getTaskInfo { - fn: import { getTaskInfo } from "@server/queries.js", - entities: [Task] -} -``` - -Wasp internally uses `esnext` module resolution, which always requires specifying the extension as `.js` (i.e., the extension used in the emitted JS file). This applies to all `@server` imports (and files on the server in general). This quirk does not apply to client files (the transpiler takes care of it). - -Read more about ES modules in TypeScript [here](https://www.typescriptlang.org/docs/handbook/esm-node.html). If you're interested in the discussion and the reasoning behind this, read about it [in this GitHub issue](https://github.com/microsoft/TypeScript/issues/33588). -::: - -## Entity Types - -Instead of manually specifying the types for `isDone` and `description`, we can get them from the `Task` entity type. Wasp will generate types for all entities and let you import them from `"@wasp/entities"`: - -```typescript title=src/server/queries.ts -import HttpError from "@wasp/core/HttpError.js" -// highlight-next-line -import { Task } from "@wasp/entities" - -// highlight-next-line -function getInfoMessage(task: Pick): string { - const isDoneText = task.isDone ? "is done" : "is not done" - return `Task '${task.description}' is ${isDoneText}.` -} - -export const getTaskInfo = async ({ id }, context) => { - const Task = context.entities.Task - const task = await Task.findUnique({ where: { id } }) - if (!task) { - throw new HttpError(404) - } - return getInfoMessage(task) -} -``` - -By doing this, we've connected the argument type of the `getInfoMessage` function with the `Task` entity. This coupling removes duplication and ensures the function keeps the correct signature even if we change the entity. Of course, the function might throw type errors depending on how we change the entity, but that's precisely what we want! - -Don't worry about typing the query function for now. We'll see how to handle this in the next section. - -Entity types are also available on the client under the same import: - -```tsx title=src/client/Main.jsx -import { Task } from "@wasp/entities" - -export function ExamplePage() {} - const task: Task = { - id: 123, - description: "Some random task", - isDone: false, - } - return
    {task.description}
    -} - -``` - -The mentioned type safety mechanisms also apply here: changing the task entity in our `.wasp` file changes the imported type, which might throw a type error and warn us that our task definition is outdated. - -## Backend type support for Queries and Actions - -Wasp automatically generates the appropriate types for all Operations (i.e., Actions and Queries) you define inside your `.wasp` file. Assuming your `.wasp` file contains the following definition: - -```wasp title=main.wasp -// ... - -query GetTaskInfo { - fn: import { getTaskInfo } from "@server/queries.js", - entities: [Task] -} -``` - -Wasp will generate a type called `GetTaskInfo`, which you can use to type the Query's implementation. By assigning the `GetTaskInfo` type to your function, you get the type information for its context. In this case, TypeScript will know the `context.entities` object must include the `Task` entity. If the Query had auth enabled, it would also know that `context` includes user information. - -`GetTaskInfo` can is a generic type that takes two (optional) type arguments: - -1. `Input` - The argument (i.e., payload) received by the query function. -2. `Output` - The query function's return type. - -Suppose you don't care about typing the Query's inputs and outputs. In that case, you can omit both type arguments, and TypeScript will infer the most general types (i.e., `never` for the input, `unknown` for the output.). - -```typescript title=src/server/queries.ts -import HttpError from "@wasp/core/HttpError.js" -import { Task } from "@wasp/entities" -// highlight-next-line -import { GetTaskInfo } from "@wasp/queries/types" - -function getInfoMessage(task: Pick): string { - const isDoneText = task.isDone ? "is done" : "is not done" - return `Task '${task.description}' is ${isDoneText}.` -} - -// Use the type parameters to specify the Query's argument and return types. -// highlight-next-line -export const getTaskInfo: GetTaskInfo, string> = async ({ id }, context) => { - // Thanks to the definition in your .wasp file, the compiler knows the type of - // `context` (and that it contains the `Task` entity). - const Task = context.entities.Task - - // Thanks to the first type argument in `GetTaskInfo`, the compiler knows `args` - // is of type `Pick`. - const task = await Task.findUnique({ where: { id } }) - if (!task) { - throw new HttpError(404) - } - - // Thanks to the second type argument in `GetTaskInfo`, the compiler knows the - // function must return a value of type `string`. - return getInfoMessage(task) -} -``` -Everything described above applies to Actions as well. -:::tip - -If don't want to define a new type for the Query's return value, the new `satisfies` keyword will allow TypeScript to infer it automatically: -```typescript -const getFoo = (async (_args, context) => { - const foos = await context.entities.Foo.findMany() - return { - foos, - message: "Here are some foos!", - queriedAt: new Date(), - } -}) satisfies GetFoo -``` -From the snippet above, TypeScript knows: -1. The correct type for `context`. -2. The Query's return type is `{ foos: Foo[], message: string, queriedAt: Date }`. - -If you don't need the context, you can skip specifying the Query's type (and arguments): -```typescript -const getFoo = () => {{ name: 'Foo', date: new Date() }} -``` - -::: - -## Frontend type support for Queries and Actions - -Wasp supports automatic full-stack type safety à la tRPC. You only need to define the Operation's type on the backend, and the frontend will automatically know how to call it. - -### Frontend type support for Queries -The examples assume you've defined the Query `getTaskInfo` from the previous sections: - -```typescript title="src/server/queries.ts" -export const getTaskInfo: GetTaskInfo, string> = - async ({ id }, context) => { - // ... - } -``` - -Wasp will use the type of `getTaskInfo` to infer the Query's types on the frontend: - -```tsx title="src/client/TaskInfo.tsx" -import { useQuery } from "@wasp/queries" -// Wasp knows the type of `getTaskInfo` thanks to your backend definition. -// highlight-next-line -import getTaskInfo from "@wasp/queries/getTaskInfo" - -export const TaskInfo = () => { - const { - // TypeScript knows `taskInfo` is a `string | undefined` thanks to the - // backend definition. - data: taskInfo, - // TypeScript also knows `isError` is a `boolean`. - isError, - // TypeScript knows `error` is of type `Error`. - error, - // TypeScript knows `id` must be a `Task["id"]` (i.e., a number) thanks to - // your backend definition. - // highlight-next-line - } = useQuery(getTaskInfo, { id: 1 }) - - if (isError) { - return
    Error during fetching tasks: {error.message || "unknown"}
    - } - - // TypeScript forces you to perform this check. - return taskInfo === undefined ? ( -
    Waiting for info...
    - ) : ( -
    {taskInfo}
    - ) -} -``` - -### Frontend type support for Actions - -Assuming the following action definition in your `.wasp` file - -```wasp title=main.wasp -action addTask { - fn: import { addTask } from "@server/actions.js" - entities: [Task] -} -``` - -And its corresponding implementation in `src/server/actions.ts`: - -```typescript title=src/server/actions.ts -import { AddTask } from "@wasp/actions/types" - -type TaskPayload = Pick - -const addTask: AddTask = async (args, context) => { - // ... -} -``` - -Here's how to use it on the frontend: -```tsx title=src/client/AddTask.tsx -import { useAction } from "@wasp/actions" -// TypeScript knows `addTask` is a function that expects a value of type -// `TaskPayload` and returns a value of type `Promise`. -import addTask from "@wasp/queries/addTask" - -const AddTask = ({ description }: Pick) => { - return ( -
    - - -
    - ) -} - -``` -#### Type support for the `useAction` hook -Type inference also works if you decide to use the action via the `useAction` hook: -```typescript -// addTaskFn is of type (args: TaskPayload) => Task -const addTaskFn = useAction(addTask) -``` - -The `useAction` hook also includes support for optimistic updates. Read [the feature docs](/docs/language/features#the-useaction-hook) to understand more about optimistic updates and how to define them in Wasp. - -Here's an example that shows how you can use static type checking in their definitions (the example assumes an appropriate action defined in the `.wasp` file and implemented on the server): - -```tsx title=Task.tsx -import { useQuery } from "@wasp/queries" -import { OptimisticUpdateDefinition, useAction } from "@wasp/actions" -import updateTaskIsDone from "@wasp/actions/updateTaskIsDone" - -type TaskPayload = Pick - -const Task = ({ taskId }: Pick) => { - const updateTaskIsDoneOptimistically = useAction( - updateTaskIsDone, - { - optimisticUpdates: [ - { - getQuerySpecifier: () => [getTask, { id: taskId }], - // This query's cache should should never be empty - updateQuery: ({ isDone }, oldTask) => ({ ...oldTask!, isDone }), - // highlight-next-line - } as OptimisticUpdateDefinition, - { - getQuerySpecifier: () => [getTasks], - updateQuery: (updatedTask, oldTasks) => - oldTasks && - oldTasks.map((task) => - task.id === updatedTask.id ? { ...task, ...updatedTask } : task - ), - // highlight-next-line - } as OptimisticUpdateDefinition, - ], - } - ) - // ... -} -``` - -## Database seeding - -When implementing a seed function in TypeScript, you can import a `DbSeedFn` type via - -```ts -import type { DbSeedFn } from "@wasp/dbSeed/types.js" -``` - -and use it to type your seed function like this: - -```ts -export const devSeedSimple: DbSeedFn = async (prismaClient) => { ... } -``` - -## CRUD operations on entities - -For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities. - -Read more about CRUD operations in Wasp [here](/docs/language/features#crud-operations). - -### Using types for CRUD operations overrides - -If you writing the override implementation in Typescript, you'll have access to generated types. The overrides are functions that take the following arguments: -- `args` - The arguments of the operation i.e. the data that's sent from the client. -- `context` - Context containing the `user` making the request and the `entities` object containing the entity that's being operated on. - -You can types for each of the functions you want to override from `@wasp/crud/{crud name}`. The types that are available are: -- `GetAllQuery` -- `GetQuery` -- `CreateAction` -- `UpdateAction` -- `DeleteAction` - -If you have a CRUD named `Tasks`, you would import the types like this: -```ts -import type { GetAllQuery, GetQuery, CreateAction, UpdateAction, DeleteAction } from '@wasp/crud/Tasks' - -// Each of the types is a generic type, so you can use it like this: -export const getAllOverride: GetAllQuery = async (args, context) => { - // ... -} -``` - -## WebSocket full-stack type support - - -Defining event names with the matching payload types on the server makes those types exposed automatically on the client. This helps you avoid mistakes when emitting events or handling them. - -### Defining the events handler -On the server, you will get Socket.IO `io: Server` argument and `context` for your WebSocket function, which contains all entities you defined in your Wasp app. You can type the `webSocketFn` function like this: - -```ts title=src/server/webSocket.ts -import type { WebSocketDefinition, WaspSocketData } from '@wasp/webSocket' - -// Using the generic WebSocketDefinition type to define the WebSocket function. -type WebSocketFn = WebSocketDefinition< - ClientToServerEvents, - ServerToClientEvents, - InterServerEvents, - SocketData -> - -interface ServerToClientEvents { - // The type for the payload of the "chatMessage" event. - chatMessage: (msg: { id: string, username: string, text: string }) => void; -} - -interface ClientToServerEvents { - // The type for the payload of the "chatMessage" event. - chatMessage: (msg: string) => void; -} - -interface InterServerEvents {} - -interface SocketData extends WaspSocketData {} - -// Use the WebSocketFn to type the webSocketFn function. -export const webSocketFn: WebSocketFn = (io, context) => { - io.on('connection', (socket) => { - socket.on('chatMessage', async (msg) => { - io.emit('chatMessage', { ... }) - }) - }) -} -``` - -### Using the WebSocket on the client - -After you have defined the WebSocket function on the server, you can use it on the client. The `useSocket` hook will give you the `socket` instance and the `isConnected` boolean. The `socket` instance is typed with the types you defined on the server. - -The `useSocketListener` hook will give you a type-safe event handler. The event name and its payload type are defined on the server. - -You can additonally use the `ClientToServerPayload` and `ServerToClientPayload` helper types to get the payload type for a specific event. - -```tsx title=src/client/ChatPage.tsx -import React, { useState } from 'react' -import { - useSocket, - useSocketListener, - ServerToClientPayload, - ClientToServerPayload, -} from '@wasp/webSocket' - -export const ChatPage = () => { - const [messageText, setMessageText] = useState< - // We are using a helper type to get the payload type for the "chatMessage" event. - ClientToServerPayload<'chatMessage'> - >('') - - const [messages, setMessages] = useState< - // We are using a helper type to get the payload type for the "chatMessage" event. - ServerToClientPayload<'chatMessage'>[] - >([]) - - // The "socket" instance is typed with the types you defined on the server. - const { socket, isConnected } = useSocket() - - // This is a type-safe event handler: "chatMessage" event and its payload type - // are defined on the server. - useSocketListener('chatMessage', logMessage) - - function logMessage(msg: ServerToClientPayload<'chatMessage'>) { - // ... - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - // This is a type-safe event emitter: "chatMessage" event and its payload type - // are defined on the server. - socket.emit('chatMessage', messageText) - setMessageText('') - } - - return ( - ... - ) -} -``` \ No newline at end of file diff --git a/web/docusaurus.config.js b/web/docusaurus.config.js index b547a1f75e..2df5ea3ef5 100644 --- a/web/docusaurus.config.js +++ b/web/docusaurus.config.js @@ -56,12 +56,16 @@ module.exports = { }, items: [ { - to: 'docs/', - activeBasePath: 'docs', - label: 'Docs', + type: 'docsVersion', position: 'left', + label: 'Docs', className: 'navbar-item-docs navbar-item-outside', }, + { + type: 'docsVersionDropdown', + position: 'left', + className: 'navbar-item-docs-version-dropdown', + }, { to: 'blog', label: 'Blog', @@ -101,11 +105,7 @@ module.exports = { { label: 'Todo app tutorial', to: 'docs/tutorial/create', - }, - { - label: 'Reference', - to: 'docs/language/features', - }, + } ], }, { @@ -137,10 +137,10 @@ module.exports = { appId: 'RG0JSZOWH4', apiKey: 'feaa2a25dc596d40418c82cd040e2cbe', indexName: 'wasp-lang', - // TODO: contextualSearch is useful when you are doing versioning, - // it searches only in v1 docs if you are searching from v1 docs. - // We should enable it if we start doing versioning. - // contextualSearch: true + // ContextualSearch is useful when you are doing versioning, + // it searches only in v1 docs if you are searching from v1 docs. + // Therefore we have it enabled, since we have multiple doc versions. + contextualSearch: true }, image: 'img/wasp_twitter_cover.png', metadata: [{ name: 'twitter:card', content: 'summary_large_image' }], @@ -156,13 +156,44 @@ module.exports = { docs: { sidebarPath: require.resolve('./sidebars.js'), sidebarCollapsible: true, - // Please change this to your repo. editUrl: 'https://github.com/wasp-lang/wasp/edit/release/web', remarkPlugins: [autoImportTabs, fileExtSwitcher], + + // ------ Configuration for multiple docs versions ------ // + + // "current" docs (under /docs) are in-progress docs, so we show them only in development. + includeCurrentVersion: process.env.NODE_ENV === 'development', + + // Uncomment line below to build and show only current docs, for faster build times + // during development, if/when needed. + // onlyIncludeVersions: process.env.NODE_ENV === 'development' ? ["current"] : undefined, + + // "versions" option here enables us to customize each version of docs individually, + // and there are also other options if we ever need to customize versioned docs further. + versions: { + ...( + (process.env.NODE_ENV === 'development') ? { + "current": { + path: "next", // Token used in the URL to address this version of docs: {baseUrl}/docs/{path}. + label: "Next", // Label shown in the documentation to address this version of docs. + noIndex: true, // these are un-released docs, we don't want search engines indexing them. + } + } : {} + ), + // Configuration example: + // "0.11.1": { + // path: "0.11.1", // default, but can be anything. + // label: "0.11.1", // default, but can be anything. + // banner: "unmaintained" + // // and more! + // }, + } + + // ------------------------------------------------------ // + }, blog: { showReadingTime: true, - // Please change this to your repo. blogSidebarCount: 'ALL', blogSidebarTitle: 'All our posts', postsPerPage: 'ALL', diff --git a/web/sidebars.js b/web/sidebars.js index f2ae06c26a..f9e1decfd3 100644 --- a/web/sidebars.js +++ b/web/sidebars.js @@ -6,8 +6,8 @@ module.exports = { collapsed: false, collapsible: false, items: [ - 'introduction/what-is-wasp', - 'introduction/getting-started', + 'introduction/introduction', + 'introduction/quick-start', 'introduction/editor-setup', ], }, @@ -67,6 +67,7 @@ module.exports = { 'auth/social-auth/google', ], }, + 'auth/entities/entities', ], }, { @@ -103,7 +104,7 @@ module.exports = { 'advanced/deployment/manually', ], }, - 'advanced/email', + 'advanced/email/email', 'advanced/jobs', 'advanced/web-sockets', 'advanced/apis', diff --git a/web/src/components/Required.tsx b/web/src/components/Required.tsx deleted file mode 100644 index 17a4f2c8eb..0000000000 --- a/web/src/components/Required.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react' - -const color = '#f59e0b' - -export function Required() { - return ( - - required - - ) -} diff --git a/web/src/components/Tag.tsx b/web/src/components/Tag.tsx new file mode 100644 index 0000000000..1724c49180 --- /dev/null +++ b/web/src/components/Tag.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' + +export const Tag = ({ + color, + children, +}: React.PropsWithChildren<{ + color: string +}>) => { + return ( + + {children} + + ) +} + +// Used to mark something as internal to +// Wasp and not to be used by the user. +export function Internal() { + return internal +} + +// Used to mark something as required e.g. required +// fields in Wasp file. +export function Required() { + return required +} diff --git a/web/src/css/custom.css b/web/src/css/custom.css index 36935dbbea..1b504d6459 100644 --- a/web/src/css/custom.css +++ b/web/src/css/custom.css @@ -132,3 +132,30 @@ font-weight: bold; font-size: var(--ifm-h6-font-size); } + +/******** DOCS NAVBAR *********/ + +/* This hides Docs version dropdown when not on docs (i.e. when we are on blog). */ +.navbar__item:has(.navbar-item-docs-version-dropdown:not(.active)) { + display: none; +} + +/******************************/ + +/******* OTHER ********/ + +.video-container { + position: relative; + width: 100%; + padding-bottom: 56.25%; + margin-bottom: 1.5rem; +} + +.video-container iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} diff --git a/web/static/img/auth-entities/model-example.png b/web/static/img/auth-entities/model-example.png new file mode 100644 index 0000000000..cd41e4107e Binary files /dev/null and b/web/static/img/auth-entities/model-example.png differ diff --git a/web/static/img/auth-entities/model.png b/web/static/img/auth-entities/model.png new file mode 100644 index 0000000000..35b98b3e2f Binary files /dev/null and b/web/static/img/auth-entities/model.png differ diff --git a/web/static/img/wasp_db_demonstration.gif b/web/static/img/wasp_db_demonstration.gif index 14bd34ebc3..97da91e55e 100644 Binary files a/web/static/img/wasp_db_demonstration.gif and b/web/static/img/wasp_db_demonstration.gif differ diff --git a/web/static/img/wasp_user_in_db.gif b/web/static/img/wasp_user_in_db.gif new file mode 100644 index 0000000000..26b84f1108 Binary files /dev/null and b/web/static/img/wasp_user_in_db.gif differ diff --git a/web/static/img/writing-rfcs/existing-solutions.png b/web/static/img/writing-rfcs/existing-solutions.png new file mode 100644 index 0000000000..8a104ef32c Binary files /dev/null and b/web/static/img/writing-rfcs/existing-solutions.png differ diff --git a/web/static/img/writing-rfcs/rfc-flowchart.png b/web/static/img/writing-rfcs/rfc-flowchart.png new file mode 100644 index 0000000000..7aea59c764 Binary files /dev/null and b/web/static/img/writing-rfcs/rfc-flowchart.png differ diff --git a/web/static/img/writing-rfcs/rfc-meme-when.png b/web/static/img/writing-rfcs/rfc-meme-when.png new file mode 100644 index 0000000000..59af014ba5 Binary files /dev/null and b/web/static/img/writing-rfcs/rfc-meme-when.png differ diff --git a/web/static/img/writing-rfcs/rfc-metadata.png b/web/static/img/writing-rfcs/rfc-metadata.png new file mode 100644 index 0000000000..81ed2e574d Binary files /dev/null and b/web/static/img/writing-rfcs/rfc-metadata.png differ diff --git a/web/static/img/writing-rfcs/rfc-overview.png b/web/static/img/writing-rfcs/rfc-overview.png new file mode 100644 index 0000000000..42ae60d88a Binary files /dev/null and b/web/static/img/writing-rfcs/rfc-overview.png differ diff --git a/web/static/img/writing-rfcs/rfc-problem.png b/web/static/img/writing-rfcs/rfc-problem.png new file mode 100644 index 0000000000..ae42c52d00 Binary files /dev/null and b/web/static/img/writing-rfcs/rfc-problem.png differ diff --git a/web/static/img/writing-rfcs/rfc-prophet.png b/web/static/img/writing-rfcs/rfc-prophet.png new file mode 100644 index 0000000000..c17b14d26e Binary files /dev/null and b/web/static/img/writing-rfcs/rfc-prophet.png differ diff --git a/web/static/img/writing-rfcs/rfc-reviewer-status-example.png b/web/static/img/writing-rfcs/rfc-reviewer-status-example.png new file mode 100644 index 0000000000..77e9e7b2a5 Binary files /dev/null and b/web/static/img/writing-rfcs/rfc-reviewer-status-example.png differ diff --git a/web/static/img/wsl-guide/wsl-guide-banner.jpeg b/web/static/img/wsl-guide/wsl-guide-banner.jpeg new file mode 100644 index 0000000000..252c5e18b2 Binary files /dev/null and b/web/static/img/wsl-guide/wsl-guide-banner.jpeg differ diff --git a/web/versioned_docs/version-0.11.8/_sendingEmailsInDevelopment.md b/web/versioned_docs/version-0.11.8/_sendingEmailsInDevelopment.md new file mode 100644 index 0000000000..e0917d5cbb --- /dev/null +++ b/web/versioned_docs/version-0.11.8/_sendingEmailsInDevelopment.md @@ -0,0 +1,7 @@ +:::info Sending emails while developing + +When you run your app in development mode, the emails are not sent. Instead, they are logged to the console. + +To enable sending emails in development mode, you need to set the `SEND_EMAILS_IN_DEVELOPMENT` env variable to `true` in your `.env.server` file. + +::: diff --git a/web/versioned_docs/version-0.11.8/advanced/apis.md b/web/versioned_docs/version-0.11.8/advanced/apis.md new file mode 100644 index 0000000000..767fce0100 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/apis.md @@ -0,0 +1,343 @@ +--- +title: Custom HTTP API Endpoints +--- + +import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' +import { Required } from '@site/src/components/Tag' + +In Wasp, the default client-server interaction mechanism is through [Operations](../data-model/operations/overview). However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an `api`. Best of all, they should look and feel very familiar. + +## How to Create an API + +APIs are used to tie a JS function to a certain endpoint e.g. `POST /something/special`. They are distinct from Operations and have no client-side helpers (like `useQuery`). + +To create a Wasp API, you must: + +1. Declare the API in Wasp using the `api` declaration +2. Define the API's NodeJS implementation + +After completing these two steps, you'll be able to call the API from the client code (via our `Axios` wrapper), or from the outside world. + +### Declaring the API in Wasp + +First, we need to declare the API in the Wasp file and you can easily do this with the `api` declaration: + + + + +```wasp title="main.wasp" +// ... + +api fooBar { // APIs and their implementations don't need to (but can) have the same name. + fn: import { fooBar } from "@server/apis.js", + httpRoute: (GET, "/foo/bar") +} +``` + + + +```wasp title="main.wasp" +// ... + +api fooBar { // APIs and their implementations don't need to (but can) have the same name. + fn: import { fooBar } from "@server/apis.js", + httpRoute: (GET, "/foo/bar") +} +``` + + + +Read more about the supported fields in the [API Reference](#api-reference). + + +### Defining the API's NodeJS Implementation + + + +:::note +To make sure the Wasp compiler generates the types for APIs for use in the NodeJS implementation, you should add your `api` declarations to your `.wasp` file first _and_ keep the `wasp start` command running. +::: + + +After you defined the API, it should be implemented as a NodeJS function that takes three arguments: + +1. `req`: Express Request object +2. `res`: Express Response object +3. `context`: An additional context object **injected into the API by Wasp**. This object contains user session information, as well as information about entities. The examples here won't use the context for simplicity purposes. You can read more about it in the [section about using entities in APIs](#using-entities-in-apis). + + + + +```ts title="src/server/apis.js" +export const fooBar = (req, res, context) => { + res.set("Access-Control-Allow-Origin", "*"); // Example of modifying headers to override Wasp default CORS middleware. + res.json({ msg: `Hello, ${context.user?.username || "stranger"}!` }); +}; +``` + + + + +```ts title="src/server/apis.ts" +import { FooBar } from "@wasp/apis/types"; // This type is generated by Wasp based on the `api` declaration above. + +export const fooBar: FooBar = (req, res, context) => { + res.set("Access-Control-Allow-Origin", "*"); // Example of modifying headers to override Wasp default CORS middleware. + res.json({ msg: `Hello, ${context.user?.username || "stranger"}!` }); +}; +``` + + + + + + +#### Providing Extra Type Information + +We'll see how we can provide extra type information to an API function. + +Let's say you wanted to create some `GET` route that would take an email address as a param, and provide them the answer to "Life, the Universe and Everything." 😀 What would this look like in TypeScript? + +Define the API in Wasp: + +```wasp title="main.wasp" +api fooBar { + fn: import { fooBar } from "@server/apis.js", + entities: [Task], + httpRoute: (GET, "/foo/bar/:email") +} +``` + +We can use the `FooBar` type to which we'll provide the generic **params** and **response** types, which then gives us full type safety in the implementation. + +```ts title="src/server/apis.ts" +import { FooBar } from "@wasp/apis/types"; + +export const fooBar: FooBar< + { email: string }, // params + { answer: number } // response +> = (req, res, _context) => { + console.log(req.params.email); + res.json({ answer: 42 }); +}; +``` + + + +## Using the API + +### Using the API externally + +To use the API externally, you simply call the endpoint using the method and path you used. + +For example, if your app is running at `https://example.com` then from the above you could issue a `GET` to `https://example/com/foo/callback` (in your browser, Postman, `curl`, another web service, etc.). + +### Using the API from the Client + +To use the API from your client, including with auth support, you can import the Axios wrapper from `@wasp/api` and invoke a call. For example: + + + + +```jsx title="src/client/pages/SomePage.jsx" +import React, { useEffect } from "react"; +import api from "@wasp/api"; + +async function fetchCustomRoute() { + const res = await api.get("/foo/bar"); + console.log(res.data); +} + +export const Foo = () => { + useEffect(() => { + fetchCustomRoute(); + }, []); + + return <>// ...; +}; +``` + + + +```tsx title="src/client/pages/SomePage.tsx" +import React, { useEffect } from "react"; +import api from "@wasp/api"; + +async function fetchCustomRoute() { + const res = await api.get("/foo/bar"); + console.log(res.data); +} + +export const Foo = () => { + useEffect(() => { + fetchCustomRoute(); + }, []); + + return <>// ...; +}; +``` + + + +#### Making Sure CORS Works + +APIs are designed to be as flexible as possible, hence they don't utilize the default middleware like Operations do. As a result, to use these APIs on the client side, you must ensure that CORS (Cross-Origin Resource Sharing) is enabled. + +You can do this by defining custom middleware for your APIs in the Wasp file. + + + + +For example, an `apiNamespace` is a simple declaration used to apply some `middlewareConfigFn` to all APIs under some specific path: + +```wasp title="main.wasp" +apiNamespace fooBar { + middlewareConfigFn: import { fooBarNamespaceMiddlewareFn } from "@server/apis.js", + path: "/foo" +} +``` + +And then in the implementation file: + +```js title="src/server/apis.js" +export const apiMiddleware = (config) => { + return config; +}; +``` + + + + +For example, an `apiNamespace` is a simple declaration used to apply some `middlewareConfigFn` to all APIs under some specific path: + +```wasp title="main.wasp" +apiNamespace fooBar { + middlewareConfigFn: import { fooBarNamespaceMiddlewareFn } from "@server/apis.js", + path: "/foo" +} +``` + +And then in the implementation file (returning the default config): + +```ts title="src/server/apis.ts" +import { MiddlewareConfigFn } from "@wasp/middleware"; +export const apiMiddleware: MiddlewareConfigFn = (config) => { + return config; +}; +``` + + + + +We are returning the default middleware which enables CORS for all APIs under the `/foo` path. + +For more information about middleware configuration, please see: [Middleware Configuration](../advanced/middleware-config) + +## Using Entities in APIs + +In many cases, resources used in APIs will be [Entities](../data-model/entities.md). +To use an Entity in your API, add it to the `api` declaration in Wasp: + + + + +```wasp {3} title="main.wasp" +api fooBar { + fn: import { fooBar } from "@server/apis.js", + entities: [Task], + httpRoute: (GET, "/foo/bar") +} +``` + + + +```wasp {3} title="main.wasp" +api fooBar { + fn: import { fooBar } from "@server/apis.js", + entities: [Task], + httpRoute: (GET, "/foo/bar") +} +``` + + + +Wasp will inject the specified Entity into the APIs `context` argument, giving you access to the Entity's Prisma API: + + + + +```ts title="src/server/apis.js" +export const fooBar = (req, res, context) => { + res.json({ count: await context.entities.Task.count() }); +}; +``` + + + + +```ts title="src/server/apis.ts" +import { FooBar } from "@wasp/apis/types"; + +export const fooBar: FooBar = (req, res, context) => { + res.json({ count: await context.entities.Task.count() }); +}; +``` + + + + +The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud). + +## API Reference + + + + +```wasp title="main.wasp" +api fooBar { + fn: import { fooBar } from "@server/apis.js", + httpRoute: (GET, "/foo/bar"), + entities: [Task], + auth: true, + middlewareConfigFn: import { apiMiddleware } from "@server/apis.js" +} +``` + + + +```wasp title="main.wasp" +api fooBar { + fn: import { fooBar } from "@server/apis.js", + httpRoute: (GET, "/foo/bar"), + entities: [Task], + auth: true, + middlewareConfigFn: import { apiMiddleware } from "@server/apis.js" +} +``` + + + +The `api` declaration has the following fields: + +- `fn: ServerImport` + + The import statement of the APIs NodeJs implementation. + +- `httpRoute: (HttpMethod, string)` + + The HTTP (method, path) pair, where the method can be one of: + + - `ALL`, `GET`, `POST`, `PUT` or `DELETE` + - and path is an Express path `string`. + +- `entities: [Entity]` + + A list of entities you wish to use inside your API. You can read more about it [here](#using-entities-in-apis). + +- `auth: bool` + + If auth is enabled, this will default to `true` and provide a `context.user` object. If you do not wish to attempt to parse the JWT in the Authorization Header, you should set this to `false`. + +- `middlewareConfigFn: ServerImport` + + The import statement to an Express middleware config function for this API. See more in [middleware section](../advanced/middleware-config) of the docs. \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/advanced/deployment/DeploymentOptionsGrid.css b/web/versioned_docs/version-0.11.8/advanced/deployment/DeploymentOptionsGrid.css new file mode 100644 index 0000000000..dfde66d8a9 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/deployment/DeploymentOptionsGrid.css @@ -0,0 +1,29 @@ +.deployment-methods-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-gap: 0.5rem; + margin-bottom: 1rem; +} +.deployment-method-box { + display: flex; + flex-direction: column; + justify-content: center; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-pagination-nav-border-radius); + padding: 1.5rem; + transition: all 0.1s ease-in-out; +} +.deployment-method-box:hover { + border-color: var(--ifm-pagination-nav-color-hover); +} +.deployment-method-box h3 { + margin: 0; + color: var(--ifm-link-color); +} +.deployment-method-box p { + margin: 0; + color: var(--ifm-color-secondary-contrast-foreground); +} +.deployment-methods-info { + color: var(--ifm-color-secondary-contrast-foreground); +} \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/advanced/deployment/DeploymentOptionsGrid.tsx b/web/versioned_docs/version-0.11.8/advanced/deployment/DeploymentOptionsGrid.tsx new file mode 100644 index 0000000000..3537d84906 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/deployment/DeploymentOptionsGrid.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import "./DeploymentOptionsGrid.css"; + +export function DeploymentOptionsGrid() { + const deploymentMethods = [ + { + title: "Using Wasp CLI", + description: "One command deployment & redeployment", + linkToDocs: "/docs/advanced/deployment/cli", + }, + { + title: "Deploying Manually", + description: "Build the app and deploy it manually", + linkToDocs: "/docs/advanced/deployment/manually", + }, + ]; + return ( + <> +
    + {deploymentMethods.map((deploymentMethod) => ( + + ))} +
    +

    + Click on each deployment method for more details. +

    + + ); +} + +function DeploymentOptionBox({ + linkToDocs, + title, + description, +}: { + linkToDocs: string; + title: string; + description: string; +}) { + return ( + +

    {title} »

    +

    {description}

    +
    + ); +} \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/advanced/deployment/_addExternalAuthEnvVarsReminder.md b/web/versioned_docs/version-0.11.8/advanced/deployment/_addExternalAuthEnvVarsReminder.md new file mode 100644 index 0000000000..10a1053187 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/deployment/_addExternalAuthEnvVarsReminder.md @@ -0,0 +1,4 @@ +:::tip Using an external auth method? + +If your app is using an external authentication method(s) supported by Wasp (such as [Google](../../auth/social-auth/google#4-adding-environment-variables) or [GitHub](../../auth/social-auth/github#4-adding-environment-variables)), make sure to set the necessary environment variables. +::: diff --git a/web/versioned_docs/version-0.11.8/advanced/deployment/_building-the-web-client.md b/web/versioned_docs/version-0.11.8/advanced/deployment/_building-the-web-client.md new file mode 100644 index 0000000000..cd49d1b191 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/deployment/_building-the-web-client.md @@ -0,0 +1,13 @@ +To build the web app, position yourself in `.wasp/build/web-app` directory: + +``` +cd .wasp/build/web-app +``` + +Run + +``` +npm install && REACT_APP_API_URL= npm run build +``` + +where `` is the URL of the Wasp server that you previously deployed. diff --git a/web/versioned_docs/version-0.11.8/advanced/deployment/cli.md b/web/versioned_docs/version-0.11.8/advanced/deployment/cli.md new file mode 100644 index 0000000000..b5b6b8dc97 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/deployment/cli.md @@ -0,0 +1,249 @@ +--- +title: Deploying with the Wasp CLI +--- + +import { Required } from '@site/src/components/Tag'; + +Wasp CLI can deploy your full-stack application with only a single command. +The command automates the manual deployment process and is the recommended way of deploying Wasp apps. + +## Supported Providers + +Wasp supports automated deployment to the following providers: + +- [Fly.io](#flyio) - they offer 5$ free credit each month +- Railway (coming soon, track it here [#1157](https://github.com/wasp-lang/wasp/pull/1157)) + +## Fly.io + +### Prerequisites + +Fly provides [free allowances](https://fly.io/docs/about/pricing/#plans) for up to 3 VMs (so deploying a Wasp app to a new account is free), but all plans require you to add your credit card information before you can proceed. If you don't, the deployment will fail. + +You can add the required credit card information on the [account's billing page](https://fly.io/dashboard/personal/billing). + +:::info Fly.io CLI +You will need the [`flyctl` CLI](https://fly.io/docs/hands-on/install-flyctl/) installed on your machine before you can deploy to Fly.io. +::: + +### Deploying + +Using the Wasp CLI, you can easily deploy a new app to [Fly.io](https://fly.io) with just a single command: + +```shell +wasp deploy fly launch my-wasp-app mia +``` + + + +Please do not CTRL-C or exit your terminal while the commands are running. + + +Under the covers, this runs the equivalent of the following commands: + +```shell +wasp deploy fly setup my-wasp-app mia +wasp deploy fly create-db mia +wasp deploy fly deploy +``` + +The commands above use the app basename `my-wasp-app` and deploy it to the _Miami, Florida (US) region_ (called `mia`). Read more about Fly.io regions [here](#flyio-regions). + +:::caution Unique Name +Your app name must be unique across all of Fly or deployment will fail. +::: + +The basename is used to create all three app tiers, resulting in three separate apps in your Fly dashboard: + +- `my-wasp-app-client` +- `my-wasp-app-server` +- `my-wasp-app-db` + +You'll notice that Wasp creates two new files in your project root directory: +- `fly-server.toml` +- `fly-client.toml` + +You should include these files in your version control so that you can deploy your app with a single command in the future. + +### Using a Custom Domain For Your App + +Setting up a custom domain is a three-step process: + +1. You need to add your domain to your Fly client app. You can do this by running: + +```shell +wasp deploy fly cmd --context client certs create mycoolapp.com +``` + +:::note Use Your Domain +Make sure to replace `mycoolapp.com` with your domain in all of the commands mentioned in this section. +::: + +This command will output the instructions to add the DNS records to your domain. It will look something like this: + +```shell-session +You can direct traffic to mycoolapp.com by: + +1: Adding an A record to your DNS service which reads + + A @ 66.241.1XX.154 + +You can validate your ownership of mycoolapp.com by: + +2: Adding an AAAA record to your DNS service which reads: + + AAAA @ 2a09:82XX:1::1:ff40 +``` + +2. You need to add the DNS records for your domain: + + _This will depend on your domain provider, but it should be a matter of adding an A record for `@` and an AAAA record for `@` with the values provided by the previous command._ + +3. You need to set your domain as the `WASP_WEB_CLIENT_URL` environment variable for your server app: + +```shell +wasp deploy fly cmd --context server secrets set WASP_WEB_CLIENT_URL=https://mycoolapp.com +``` + + + +We need to do this to keep our CORS configuration up to date. + + +That's it, your app should be available at `https://mycoolapp.com`! 🎉 + +## API Reference + +### `launch` + +`launch` is a convenience command that runs `setup`, `create-db`, and `deploy` in sequence. + +```shell +wasp deploy fly launch +``` + +It accepts the following arguments: + +- `` - the name of your app +- `` - the region where your app will be deployed + + Read how to find the available regions [here](#flyio-regions). + +It gives you the same result as running the following commands: + +```shell +wasp deploy fly setup +wasp deploy fly create-db +wasp deploy fly deploy +``` + +#### Environment Variables + +If you are deploying an app that requires any other environment variables (like social auth secrets), you can set them with the `--server-secret` option: + +``` +wasp deploy fly launch my-wasp-app mia --server-secret GOOGLE_CLIENT_ID=<...> --server-secret GOOGLE_CLIENT_SECRET=<...> +``` + +### `setup` + +`setup` will create your client and server apps on Fly, and add some secrets, but does _not_ deploy them. + +```shell +wasp deploy fly setup +``` + +It accepts the following arguments: + +- `` - the name of your app +- `` - the region where your app will be deployed + + Read how to find the available regions [here](#flyio-regions). + +After running `setup`, Wasp creates two new files in your project root directory: `fly-server.toml` and `fly-client.toml`. +You should include these files in your version control. + +You **can edit the `fly-server.toml` and `fly-client.toml` files** to further configure your Fly deployments. Wasp will use the TOML files when you run `deploy`. + +If you want to maintain multiple apps, you can add the `--fly-toml-dir ` option to point to different directories, like "dev" or "staging". + +:::caution Execute Only Once +You should only run `setup` once per app. If you run it multiple times, it will create unnecessary apps on Fly. +::: + +### `create-db` + +`create-db` will create a new database for your app. + +```shell +wasp deploy fly create-db +``` + +It accepts the following arguments: + +- `` - the region where your app will be deployed + + Read how to find the available regions [here](#flyio-regions). + +:::caution Execute Only Once +You should only run `create-db` once per app. If you run it multiple times, it will create multiple databases, but your app needs only one. +::: + +### `deploy` + +```shell +wasp deploy fly deploy +``` + +`deploy` pushes your client and server live. + +Run this command whenever you want to **update your deployed app** with the latest changes: + +```shell +wasp deploy fly deploy +``` + +### `cmd` + +If want to run arbitrary Fly commands (e.g. `flyctl secrets list` for your server app), here's how to do it: + +```shell +wasp deploy fly cmd secrets list --context server +``` + +### Fly.io Regions + +> Fly.io runs applications physically close to users: in datacenters around the world, on servers we run ourselves. You can currently deploy your apps in 34 regions, connected to a global Anycast network that makes sure your users hit our nearest server, whether they’re in Tokyo, São Paolo, or Frankfurt. + + + +Read more on Fly regions [here](https://fly.io/docs/reference/regions/). + + +You can find the list of all available Fly regions by running: + +```shell +flyctl platform regions +``` + +#### Environment Variables + +If you are deploying an app that requires any other environment variables (like social auth secrets), you can set them with the `secrets set` command: + +``` +wasp deploy fly cmd secrets set GOOGLE_CLIENT_ID=<...> GOOGLE_CLIENT_SECRET=<...> --context=server +``` + +### Mutliple Fly Organizations + +If you have multiple organizations, you can specify a `--org` option. For example: + +```shell +wasp deploy fly launch my-wasp-app mia --org hive +``` + +### Building Locally + +Fly.io offers support for both **locally** built Docker containers and **remotely** built ones. However, for simplicity and reproducibility, the CLI defaults to the use of a remote Fly.io builder. + +If you want to build locally, supply the `--build-locally` option to `wasp deploy fly launch` or `wasp deploy fly deploy`. diff --git a/web/versioned_docs/version-0.11.8/advanced/deployment/manually.md b/web/versioned_docs/version-0.11.8/advanced/deployment/manually.md new file mode 100644 index 0000000000..7767ab8a1d --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/deployment/manually.md @@ -0,0 +1,553 @@ +--- +title: Deploying Manually +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; +import AddExternalAuthEnvVarsReminder from './\_addExternalAuthEnvVarsReminder.md' +import BuildingTheWebClient from './\_building-the-web-client.md' + +We'll cover how to deploy your Wasp app manually to a variety of providers: + +- [Fly.io](#flyio) +- [Netlify](#netlify) +- [Railway](#railway) +- [Heroku](#heroku) + +## Deploying a Wasp App + +Deploying a Wasp app comes down to the following: + +1. Generating deployable code. +1. Deploying the API server (backend). +1. Deploying the web client (frontend). +1. Deploying a PostgreSQL database and keeping it running. + +Let's go through each of these steps. + +### 1. Generating Deployable Code + +Running the command `wasp build` generates deployable code for the whole app in the `.wasp/build/` directory. + +``` +wasp build +``` + +:::caution PostgreSQL in production +You won't be able to build the app if you are using SQLite as a database (which is the default database). +You'll have to [switch to PostgreSQL](../../data-model/backends#migrating-from-sqlite-to-postgresql) before deploying to production. +::: + +### 2. Deploying the API Server (backend) + +There's a Dockerfile that defines an image for building the server in the `.wasp/build` directory. + +To run the server in production, deploy this Docker image to a hosting provider and ensure it has access to correct environment variables (this varies depending on the provider). +All necessary environment variables are listed in the next section. + +#### Environment Variables + +Here are the environment variables your server requires to run: + +- `PORT` + + The server's HTTP port number. This is where the server listens for requests (e.g., `3001`). + +- `DATABASE_URL` + + The URL of the Postgres database you want your app to use (e.g., `postgresql://mydbuser:mypass@localhost:5432/nameofmydb`). + +- `WASP_WEB_CLIENT_URL` + + The URL where you plan to deploy your frontend app is running (e.g., `https://.netlify.app`). + The server needs to know about it to properly configure Same-Origin Policy (CORS) headers. + +- `JWT_SECRET` + + You only need this environment variable if you're using Wasp's `auth` features. + Set it to a random string at least 32 characters long (you can use an [online generator](https://djecrety.ir/)). + + + +### 3. Deploying the Web Client (frontend) + + + +The command above will build the web client and put it in the `build/` directory in the `web-app` directory. + +Since the app's frontend is just a bunch of static files, you can deploy it to any static hosting provider. + +### 4. Deploying the Database + +Any PostgreSQL database will do, as long as you set the `DATABASE_URL` env var correctly and ensure that the database is accessible from the server. + +## Different Providers + +We'll cover a few different deployment providers below: + +- Fly.io (server and database) +- Netlify (client) +- Railway (server, client and database) +- Heroku (server and database) + +## Fly.io + +:::tip We automated this process for you +If you want to do all of the work below with one command, you can use the [Wasp CLI](../../advanced/deployment/cli#flyio). + +Wasp CLI deploys the server, deploys the client, and sets up a database. +It also gives you a way to redeploy (update) your app with a single command. +::: + +Fly.io offers a variety of free services that are perfect for deploying your first Wasp app! You will need a Fly.io account and the [`flyctl` CLI](https://fly.io/docs/hands-on/install-flyctl/). + +:::note +Fly.io offers support for both locally built Docker containers and remotely built ones. However, for simplicity and reproducibility, we will default to the use of a remote Fly.io builder. + +Additionally, `fly` is a symlink for `flyctl` on most systems and they can be used interchangeably. +::: + +Make sure you are logged in with `flyctl` CLI. You can check if you are logged in with `flyctl auth whoami`, and if you are not, you can log in with `flyctl auth login`. + +### Set Up a Fly.io App + +:::info +You need to do this only once per Wasp app. +::: + +Unless you already have a Fly.io app that you want to deploy to, let's create a new Fly.io app. + +After you have [built the app](#1-generating-deployable-code), position yourself in `.wasp/build/` directory: + +```shell +cd .wasp/build +``` + +Next, run the launch command to set up a new app and create a `fly.toml` file: + +```bash +flyctl launch --remote-only +``` + +This will ask you a series of questions, such as asking you to choose a region and whether you'd like a database. + +- Say **yes** to **Would you like to set up a Postgresql database now?** and select **Development**. Fly.io will set a `DATABASE_URL` for you. +- Say **no** to **Would you like to deploy now?** (and to any additional questions). + + We still need to set up several environment variables. + +:::info What if the database setup fails? +If your attempts to initiate a new app fail for whatever reason, then you should run `flyctl apps destroy ` before trying again. Fly does not allow you to create multiple apps with the same name. + +
    + + What does it look like when your DB is deployed correctly? + +
    +

    When your DB is deployed correctly, you'll see it in the Fly.io dashboard:

    + image +
    +
    +::: + +Next, let's copy the `fly.toml` file up to our Wasp project dir for safekeeping. +```shell +cp fly.toml ../../ +``` + +Next, let's add a few more environment variables: + +```bash +flyctl secrets set PORT=8080 +flyctl secrets set JWT_SECRET= +flyctl secrets set WASP_WEB_CLIENT_URL= +``` + +:::note +If you do not know what your frontend URL is yet, don't worry. You can set `WASP_WEB_CLIENT_URL` after you deploy your frontend. +::: + + + +If you want to make sure you've added your secrets correctly, run `flyctl secrets list` in the terminal. Note that you will see hashed versions of your secrets to protect your sensitive data. + +### Deploy to a Fly.io App + +While still in the `.wasp/build/` directory, run: + +```bash +flyctl deploy --remote-only --config ../../fly.toml +``` + +This will build and deploy the backend of your Wasp app on Fly.io to `https://.fly.dev` 🤘🎸 + +Now, if you haven't, you can deploy your frontend and add the client url by running `flyctl secrets set WASP_WEB_CLIENT_URL=`. We suggest using [Netlify](#netlify) for your frontend, but you can use any static hosting provider. + +Additionally, some useful `flyctl` commands: + +```bash +flyctl logs +flyctl secrets list +flyctl ssh console +``` + +### Redeploying After Wasp Builds + +When you rebuild your Wasp app (with `wasp build`), it will remove your `.wasp/build/` directory. In there, you may have a `fly.toml` from any prior Fly.io deployments. + +While we will improve this process in the future, in the meantime, you have a few options: + +1. Copy the `fly.toml` file to a versioned directory, like your Wasp project dir. + + From there, you can reference it in `flyctl deploy --config ` commands, like above. + +1. Backup the `fly.toml` file somewhere before running `wasp build`, and copy it into .wasp/build/ after. + + When the `fly.toml` file exists in .wasp/build/ dir, you do not need to specify the `--config `. + +1. Run `flyctl config save -a ` to regenerate the `fly.toml` file from the remote state stored in Fly.io. + +## Netlify + +Netlify is a static hosting solution that is free for many use cases. You will need a Netlify account and [Netlify CLI](https://docs.netlify.com/cli/get-started/) installed to follow these instructions. + +Make sure you are logged in with Netlify CLI. You can check if you are logged in with `netlify status`, and if you are not, you can log in with `netlify login`. + +First, make sure you have [built the Wasp app](#1-generating-deployable-code). We'll build the client web app next. + + + +We can now deploy the client with: + +```shell +netlify deploy +``` + + + +Carefully follow the instructions i.e. do you want to create a new app or use an existing one, the team under which your app will reside etc. + + + +The final step is to run: + +```shell +netlify deploy --prod` +``` + +That is it! Your client should be live at `https://.netlify.app` ✨ + +:::note +Make sure you set this URL as the `WASP_WEB_CLIENT_URL` environment variable in your server hosting environment (e.g., Fly.io or Heroku). +::: + +## Railway + +Railway is a simple and great way to host your server and database. It's also possible to deploy your entire app: database, server, and client. You can use the platform for free for a limited time, or if you meet certain eligibility requirements. See their [plans page](https://docs.railway.app/reference/plans) for more info. + +### Prerequisites + +To get started, follow these steps: + +1. Make sure your Wasp app is built by running `wasp build` in the project dir. +2. Create a [Railway](https://railway.app/) account + + :::tip Free Tier + Sign up with your GitHub account to be eligible for the free tier + ::: + +3. Install the [Railway CLI](https://docs.railway.app/develop/cli#installation) +4. Run `railway login` and a browser tab will open to authenticate you. + +### Create New Project + +Let's create our Railway project: + +1. Go to your [Railway dashboard](https://railway.app/dashboard), click on **New Project**, and select `Provision PostgreSQL` from the dropdown menu. +2. Once it initializes, right-click on the **New** button in the top right corner and select **Empty Service**. +3. Once it initializes, click on it, go to **Settings > General** and change the name to `server` +4. Go ahead and create another empty service and name it `client` + +![Changing the name](/img/deploying/railway-rename.png) + +### Deploy Your App to Railway + +#### Setup Domains + +We'll need the domains for both the `server` and `client` services: + +1. Go to the `server` instance's `Settings` tab, and click `Generate Domain`. +2. Do the same under the `client`'s `Settings`. + +Copy the domains as we will need them later. + +#### Deploying the Server + +Let's deploy our server first: + +1. Move into your app's `.wasp/build/` directory: + + ```shell + cd .wasp/build + ``` + +2. Link your app build to your newly created Railway project: + + ```shell + railway link + ``` + +3. Go into the Railway dashboard and set up the required env variables: + + Open the `Settings` and go to the `Variables` tab: + + - click **Variable reference** and select `DATABASE_URL` (it will populate it with the correct value) + - add `WASP_WEB_CLIENT_URL` - enter the the `client` domain (e.g. `https://client-production-XXXX.up.railway.app`) + - add `JWT_SECRET` - enter a random string at least 32 characters long (use an [online generator](https://djecrety.ir/)) + + + +4. Push and deploy the project: + +```shell +railway up +``` + +Select `server` when prompted with `Select Service`. + +Railway will now locate the Dockerfile and deploy your server 👍 + +#### Deploying the Client + +1. Next, change into your app's frontend build directory `.wasp/build/web-app`: + + ```shell + cd web-app + ``` + +2. Create the production build, using the `server` domain as the `REACT_APP_API_URL`: + + ```shell + npm install && REACT_APP_API_URL= npm run build + ``` + +3. Next, we want to link this specific frontend directory to our project as well: + + ```shell + railway link + ``` + +4. We need to configure Railway's static hosting for our client. + + :::info Setting Up Static Hosting + + Copy the `build` folder within the `web-app` directory to `dist`: + + ```shell + cp -r build dist + ``` + + We'll need to create the following files: + + - `Dockerfile` with: + + ```Dockerfile title="Dockerfile" + FROM pierrezemb/gostatic + CMD [ "-fallback", "index.html" ] + COPY ./dist/ /srv/http/ + ``` + + - `.dockerignore` with: + ```bash title=".dockerignore" + node_modules/ + ``` + + You'll need to repeat these steps **each time** you run `wasp build` as it will remove the `.wasp/build/web-app` directory. + +
    + + Here's a useful shell script to do the process + + + If you want to automate the process, save the following as `deploy_client.sh` in the root of your project: + + ```bash title="deploy_client.sh" + #!/usr/bin/env bash + + if [ -z "$REACT_APP_API_URL" ] + then + echo "REACT_APP_API_URL is not set" + exit 1 + fi + + wasp build + cd .wasp/build/web-app + + npm install && REACT_APP_API_URL=$REACT_APP_API_URL npm run build + + cp -r build dist + + dockerfile_contents=$(cat < Dockerfile + echo "$dockerignore_contents" > .dockerignore + + railway up + ``` + + Make it executable with: + + ```shell + chmod +x deploy_client.sh + ``` + + You can run it with: + + ```shell + REACT_APP_API_URL= ./deploy_client.sh + ``` + +
    + ::: + +5. Set the `PORT` environment variable to `8043` under the `Variables` tab. + +6. Deploy the client and select `client` when prompted with `Select Service`: + +```shell +railway up +``` + +#### Conclusion + +And now your Wasp should be deployed! 🐝 🚂 🚀 + +Back in your [Railway dashboard](https://railway.app/dashboard), click on your project and you should see your newly deployed services: Postgres, Server, and Client. + +### Updates & Redeploying + +When you make updates and need to redeploy: + +- run `wasp build` to rebuild your app +- run `railway up` in the `.wasp/build` directory (server) +- repeat all the steps in the `.wasp/build/web-app` directory (client) + +## Heroku + +:::note +Heroku used to offer free apps under certain limits. However, as of November 28, 2022, they ended support for their free tier. https://blog.heroku.com/next-chapter + +As such, we recommend using an alternative provider like [Fly.io](#flyio) for your first apps. +::: + +You will need Heroku account, `heroku` [CLI](https://devcenter.heroku.com/articles/heroku-cli) and `docker` CLI installed to follow these instructions. + +Make sure you are logged in with `heroku` CLI. You can check if you are logged in with `heroku whoami`, and if you are not, you can log in with `heroku login`. + +### Set Up a Heroku App + +:::info +You need to do this only once per Wasp app. +::: + +Unless you want to deploy to an existing Heroku app, let's create a new Heroku app: + +``` +heroku create +``` + +Unless you have an external Postgres database that you want to use, let's create a new database on Heroku and attach it to our app: + +``` +heroku addons:create --app heroku-postgresql:mini +``` + +:::caution +Heroku does not offer a free plan anymore and `mini` is their cheapest database instance - it costs $5/mo. +::: + +Heroku will also set `DATABASE_URL` env var for us at this point. If you are using an external database, you will have to set it up yourself. + +The `PORT` env var will also be provided by Heroku, so the only two left to set are the `JWT_SECRET` and `WASP_WEB_CLIENT_URL` env vars: + +``` +heroku config:set --app JWT_SECRET= +heroku config:set --app WASP_WEB_CLIENT_URL= +``` + +:::note +If you do not know what your frontend URL is yet, don't worry. You can set `WASP_WEB_CLIENT_URL` after you deploy your frontend. +::: + +### Deploy to a Heroku App + +After you have [built the app](#1-generating-deployable-code), position yourself in `.wasp/build/` directory: + +```shell +cd .wasp/build +``` + +assuming you were at the root of your Wasp project at that moment. + +Log in to Heroku Container Registry: + +```shell +heroku container:login +``` + +Build the docker image and push it to Heroku: + +```shell +heroku container:push --app web +``` + +App is still not deployed at this point. +This step might take some time, especially the very first time, since there are no cached docker layers. + +:::note Note for Apple Silicon Users +Apple Silicon users need to build a non-Arm image, so the above step will not work at this time. Instead of `heroku container:push`, users instead should: + +```shell +docker buildx build --platform linux/amd64 -t . +docker tag registry.heroku.com//web +docker push registry.heroku.com//web +``` + +You are now ready to proceed to the next step. +::: + +Deploy the pushed image and restart the app: + +```shell +heroku container:release --app web +``` + +This is it, the backend is deployed at `https://-XXXX.herokuapp.com` 🎉 + +Find out the exact app URL with: + +```shell +heroku info --app +``` + +Additionally, you can check out the logs with: + +```shell +heroku logs --tail --app +``` + +:::note Using `pg-boss` with Heroku + +If you wish to deploy an app leveraging [Jobs](../../advanced/jobs) that use `pg-boss` as the executor to Heroku, you need to set an additional environment variable called `PG_BOSS_NEW_OPTIONS` to `{"connectionString":"","ssl":{"rejectUnauthorized":false}}`. This is because pg-boss uses the `pg` extension, which does not seem to connect to Heroku over SSL by default, which Heroku requires. Additionally, Heroku uses a self-signed cert, so we must handle that as well. + +Read more: https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js +::: diff --git a/web/versioned_docs/version-0.11.8/advanced/deployment/overview.md b/web/versioned_docs/version-0.11.8/advanced/deployment/overview.md new file mode 100644 index 0000000000..c4750284e2 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/deployment/overview.md @@ -0,0 +1,44 @@ +--- +title: Overview +--- + +import { DeploymentOptionsGrid } from './DeploymentOptionsGrid.tsx'; + +Wasp apps are full-stack apps that consist of: +- A Node.js server. +- A static client. +- A PostgreSQL database. + +You can deploy each part **anywhere** where you can usually deploy Node.js apps or static apps. For example, you can deploy your client on [Netlify](https://www.netlify.com/), the server on [Fly.io](https://fly.io/), and the database on [Neon](https://neon.tech/). + +To make deploying as smooth as possible, Wasp also offers a single-command deployment through the **Wasp CLI**. Read more about deploying through the CLI [here](../../advanced/deployment/cli). + + + +Regardless of how you choose to deploy your app (i.e., manually or using the Wasp CLI), you'll need to know about some common patterns covered below. + +## Customizing the Dockerfile +By default, Wasp generates a multi-stage Dockerfile. +This file is used to build and run a Docker image with the Wasp-generated server code. +It also runs any pending migrations. + +You can **add extra steps to this multi-stage `Dockerfile`** by creating your own `Dockerfile` in the project's root directory. +If Wasp finds a Dockerfile in the project's root, it appends its contents at the _bottom_ of the default multi-stage Dockerfile. + +Since the last definition in a Dockerfile wins, you can override or continue from any existing build stages. +You can also choose not to use any of our build stages and have your own custom Dockerfile used as-is. + +A few things to keep in mind: + +- If you override an intermediate build stage, no later build stages will be used unless you reproduce them below. +- The generated Dockerfile's content is dynamic and depends on which features your app uses. The content can also change in future releases, so please verify it from time to time. +- Make sure to supply `ENTRYPOINT` in your final build stage. Your changes won't have any effect if you don't. + +Read more in the official Docker docs on [multi-stage builds](https://docs.docker.com/build/building/multi-stage/). + +To see what your project's (potentially combined) Dockerfile will look like, run: +```shell +wasp dockerfile +``` + +Join our [Discord](https://discord.gg/rzdnErX) if you have any questions, or if you need more customization than this hook provides. diff --git a/web/docs/advanced/email.md b/web/versioned_docs/version-0.11.8/advanced/email.md similarity index 99% rename from web/docs/advanced/email.md rename to web/versioned_docs/version-0.11.8/advanced/email.md index 7c476b31fd..d1d9235428 100644 --- a/web/docs/advanced/email.md +++ b/web/versioned_docs/version-0.11.8/advanced/email.md @@ -4,7 +4,7 @@ title: Sending Emails import SendingEmailsInDevelopment from '../\_sendingEmailsInDevelopment.md' -import { Required } from '@site/src/components/Required' +import { Required } from '@site/src/components/Tag' import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' # Sending Emails diff --git a/web/versioned_docs/version-0.11.8/advanced/jobs.md b/web/versioned_docs/version-0.11.8/advanced/jobs.md new file mode 100644 index 0000000000..df59da9c33 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/jobs.md @@ -0,0 +1,426 @@ +--- +title: Recurring Jobs +--- + +import { Required } from '@site/src/components/Tag' +import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' + +In most web apps, users send requests to the server and receive responses with some data. When the server responds quickly, the app feels responsive and smooth. + +What if the server needs extra time to fully process the request? This might mean sending an email or making a slow HTTP request to an external API. In that case, it's a good idea to respond to the user as soon as possible and do the remaining work in the background. + +Wasp supports background jobs that can help you with this: + - Jobs persist between server restarts, + - Jobs can be retried if they fail, + - Jobs can be delayed until a future time, + - Jobs can have a recurring schedule. + +## Using Jobs + +### Job Definition and Usage + +Let's write an example Job that will print a message to the console and return a list of tasks from the database. + +1. Start by creating a Job declaration in your `.wasp` file: + + + + + ```wasp title="main.wasp" + job mySpecialJob { + executor: PgBoss, + perform: { + fn: import { foo } from "@server/workers/bar.js" + }, + entities: [Task], + } + ``` + + + + ```wasp title="main.wasp" + job mySpecialJob { + executor: PgBoss, + perform: { + fn: import { foo } from "@server/workers/bar.js" + }, + entities: [Task], + } + ``` + + + +2. After declaring the Job, implement its worker function: + + + + + ```js title="bar.js" + export const foo = async ({ name }, context) => { + console.log(`Hello ${name}!`) + const tasks = await context.entities.Task.findMany({}) + return { tasks } + } + ``` + + + + ```ts title="bar.ts" + import type { MySpecialJob } from '@wasp/jobs/mySpecialJob' + import type { Task } from '@wasp/entities' + + type Input = { name: string; } + type Output = { tasks: Task[]; } + + export const foo: MySpecialJob = async ({ name }, context) => { + console.log(`Hello ${name}!`) + const tasks = await context.entities.Task.findMany({}) + return { tasks } + } + ``` + + + + :::info The worker function + The worker function must be an `async` function. The function's return value represents the Job's result. + + The worker function accepts two arguments: + - `args`: The data passed into the job when it's submitted. + - `context: { entities }`: The context object containing entities you put in the Job declaration. + ::: + + + + `MySpecialJob` is a generic type Wasp generates to help you correctly type the Job's worker function, ensuring type information about the function's arguments and return value. Read more about type-safe jobs in the [Javascript API section](#javascript-api). + + +3. After successfully defining the job, you can submit work to be done in your [Operations](../data-model/operations/overview) or [setupFn](../project/server-config#setup-function) (or any other NodeJS code): + + + + + ```js title="someAction.js" + import { mySpecialJob } from '@wasp/jobs/mySpecialJob.js' + + const submittedJob = await mySpecialJob.submit({ job: "Johnny" }) + + // Or, if you'd prefer it to execute in the future, just add a .delay(). + // It takes a number of seconds, Date, or ISO date string. + await mySpecialJob + .delay(10) + .submit({ name: "Johnny" }) + ``` + + + + ```ts title="someAction.ts" + import { mySpecialJob } from '@wasp/jobs/mySpecialJob.js' + + const submittedJob = await mySpecialJob.submit({ job: "Tony" }) + + // Or, if you'd prefer it to execute in the future, just add a .delay(). + // It takes a number of seconds, Date, or ISO date string. + await mySpecialJob + .delay(10) + .submit({ name: "Tony" }) + ``` + + + +And that'is it. Your job will be executed by `PgBoss` as if you called `foo({ name: "Johnny" })`. + +In our example, `foo` takes an argument, but passing arguments to jobs is not a requirement. It depends on how you've implemented your worker function. + +### Recurring Jobs + +If you have work that needs to be done on some recurring basis, you can add a `schedule` to your job declaration: + + + + +```wasp {6-9} title="main.wasp" +job mySpecialJob { + executor: PgBoss, + perform: { + fn: import { foo } from "@server/workers/bar.js" + }, + schedule: { + cron: "0 * * * *", + args: {=json { "job": "args" } json=} // optional + } +} +``` + + + +```wasp {6-9} title="main.wasp" +job mySpecialJob { + executor: PgBoss, + perform: { + fn: import { foo } from "@server/workers/bar.js" + }, + schedule: { + cron: "0 * * * *", + args: {=json { "job": "args" } json=} // optional + } +} +``` + + + +In this example, you _don't_ need to invoke anything in JavaScriptTypescript. You can imagine `foo({ job: "args" })` getting automatically scheduled and invoked for you every hour. + + + + +## API Reference + +### Declaring Jobs + + + + +```wasp title="main.wasp" +job mySpecialJob { + executor: PgBoss, + perform: { + fn: import { foo } from "@server/workers/bar.js", + executorOptions: { + pgBoss: {=json { "retryLimit": 1 } json=} + } + }, + schedule: { + cron: "*/5 * * * *", + args: {=json { "foo": "bar" } json=}, + executorOptions: { + pgBoss: {=json { "retryLimit": 0 } json=} + } + }, + entities: [Task], +} +``` + + + +```wasp title="main.wasp" +job mySpecialJob { + executor: PgBoss, + perform: { + fn: import { foo } from "@server/workers/bar.js", + executorOptions: { + pgBoss: {=json { "retryLimit": 1 } json=} + } + }, + schedule: { + cron: "*/5 * * * *", + args: {=json { "foo": "bar" } json=}, + executorOptions: { + pgBoss: {=json { "retryLimit": 0 } json=} + } + }, + entities: [Task], +} +``` + + + +The Job declaration has the following fields: + +- `executor: JobExecutor` + + :::note Job executors + Our jobs need job executors to handle the _scheduling, monitoring, and execution_. + + `PgBoss` is currently our only job executor, and is recommended for low-volume production use cases. It requires your `app.db.system` to be `PostgreSQL`. + ::: + + We have selected [pg-boss](https://github.com/timgit/pg-boss/) as our first job executor to handle the low-volume, basic job queue workloads many web applications have. By using PostgreSQL (and [SKIP LOCKED](https://www.2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5/)) as its storage and synchronization mechanism, it allows us to provide many job queue pros without any additional infrastructure or complex management. + + :::info + Keep in mind that pg-boss jobs run alongside your other server-side code, so they are not appropriate for CPU-heavy workloads. Additionally, some care is required if you modify scheduled jobs. Please see pg-boss details below for more information. + +
    + pg-boss details + + pg-boss provides many useful features, which can be found [here](https://github.com/timgit/pg-boss/blob/8.4.2/README.md). + + When you add pg-boss to a Wasp project, it will automatically add a new schema to your database called `pgboss` with some internal tracking tables, including `job` and `schedule`. pg-boss tables have a `name` column in most tables that will correspond to your Job identifier. Additionally, these tables maintain arguments, states, return values, retry information, start and expiration times, and other metadata required by pg-boss. + + If you need to customize the creation of the pg-boss instance, you can set an environment variable called `PG_BOSS_NEW_OPTIONS` to a stringified JSON object containing [these initialization parameters](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#newoptions). **NOTE**: Setting this overwrites all Wasp defaults, so you must include database connection information as well. + + ### pg-boss considerations + - Wasp starts pg-boss alongside your web server's application, where both are simultaneously operational. This means that jobs running via pg-boss and the rest of the server logic (like Operations) share the CPU, therefore you should avoid running CPU-intensive tasks via jobs. + - Wasp does not (yet) support independent, horizontal scaling of pg-boss-only applications, nor starting them as separate workers/processes/threads. + - The job name/identifier in your `.wasp` file is the same name that will be used in the `name` column of pg-boss tables. If you change a name that had a `schedule` associated with it, pg-boss will continue scheduling those jobs but they will have no handlers associated, and will thus become stale and expire. To resolve this, you can remove the applicable row from the `schedule` table in the `pgboss` schema of your database. + - If you remove a `schedule` from a job, you will need to do the above as well. + - If you wish to deploy to Heroku, you need to set an additional environment variable called `PG_BOSS_NEW_OPTIONS` to `{"connectionString":"","ssl":{"rejectUnauthorized":false}}`. This is because pg-boss uses the `pg` extension, which does not seem to connect to Heroku over SSL by default, which Heroku requires. Additionally, Heroku uses a self-signed cert, so we must handle that as well. + - https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js + +
    + + ::: + +- `perform: dict` + + - `fn: ServerImport` + + - An `async` function that performs the work. Since Wasp executes Jobs on the server, you must import it from `@server`. + - It receives the following arguments: + - `args: Input`: The data passed to the job when it's submitted. + - `context: { entities: Entities }`: The context object containing any declared entities. + + Here's an example of a `perform.fn` function: + + + + + ```js title="bar.js" + export const foo = async ({ name }, context) => { + console.log(`Hello ${name}!`) + const tasks = await context.entities.Task.findMany({}) + return { tasks } + } + ``` + + + + ```ts title="bar.ts" + import { MySpecialJob } from '@wasp/jobs/mySpecialJob' + + type Input = { name: string; } + type Output = { tasks: Task[]; } + + export const foo: MySpecialJob = async (args, context) => { + console.log(`Hello ${name}!`) + const tasks = await context.entities.Task.findMany({}) + return { tasks } + } + ``` + + Read more about type-safe jobs in the [Javascript API section](#javascript-api). + + + + - `executorOptions: dict` + + Executor-specific default options to use when submitting jobs. These are passed directly through and you should consult the documentation for the job executor. These can be overridden during invocation with `submit()` or in a `schedule`. + + - `pgBoss: JSON` + + See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#sendname-data-options). + +- `schedule: dict` + + - `cron: string` + + A 5-placeholder format cron expression string. See rationale for minute-level precision [here](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#scheduling). + + _If you need help building cron expressions, Check out_ [Crontab guru](https://crontab.guru/#0_*_*_*_*). + + - `args: JSON` + + The arguments to pass to the `perform.fn` function when invoked. + + - `executorOptions: dict` + + Executor-specific options to use when submitting jobs. These are passed directly through and you should consult the documentation for the job executor. The `perform.executorOptions` are the default options, and `schedule.executorOptions` can override/extend those. + + - `pgBoss: JSON` + + See the docs for [pg-boss](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#sendname-data-options). + +- `entities: [Entity]` + + A list of entities you wish to use inside your Job (similar to [Queries and Actions](../data-model/operations/queries#using-entities-in-queries)). + +### JavaScript API + +- Importing a Job: + + + + + ```js title="someAction.js" + import { mySpecialJob } from '@wasp/jobs/mySpecialJob.js' + ``` + + + + ```ts title="someAction.ts" + import { mySpecialJob, type MySpecialJob } from '@wasp/jobs/mySpecialJob.js' + ``` + + :::info Type-safe jobs + Wasp generates a generic type for each Job declaration, which you can use to type your `perform.fn` function. The type is named after the job declaration, and is available in the `@wasp/jobs/{jobName}` module. In the example above, the type is `MySpecialJob`. + + The type takes two type arguments: + - `Input`: The type of the `args` argument of the `perform.fn` function. + - `Output`: The type of the return value of the `perform.fn` function. + ::: + + + + +- `submit(jobArgs, executorOptions)` + - `jobArgs: Input` + - `executorOptions: object` + + Submits a Job to be executed by an executor, optionally passing in a JSON job argument your job handler function receives, and executor-specific submit options. + + + + + ```js title="someAction.js" + const submittedJob = await mySpecialJob.submit({ job: "args" }) + ``` + + + + ```js title="someAction.ts" + const submittedJob = await mySpecialJob.submit({ job: "args" }) + ``` + + + +- `delay(startAfter)` + - `startAfter: int | string | Date` + + Delaying the invocation of the job handler. The delay can be one of: + - Integer: number of seconds to delay. [Default 0] + - String: ISO date string to run at. + - Date: Date to run at. + + + + + ```js title="someAction.js" + const submittedJob = await mySpecialJob + .delay(10) + .submit({ job: "args" }, { "retryLimit": 2 }) + ``` + + + + ```ts title="someAction.ts" + const submittedJob = await mySpecialJob + .delay(10) + .submit({ job: "args" }, { "retryLimit": 2 }) + ``` + + + +#### Tracking +The return value of `submit()` is an instance of `SubmittedJob`, which has the following fields: +- `jobId`: The ID for the job in that executor. +- `jobName`: The name of the job you used in your `.wasp` file. +- `executorName`: The Symbol of the name of the job executor. + - For pg-boss, you can import a Symbol from: `import { PG_BOSS_EXECUTOR_NAME } from '@wasp/jobs/core/pgBoss/pgBossJob.js'` if you wish to compare against `executorName`. + +There are also some namespaced, job executor-specific objects. + +- For pg-boss, you may access: `pgBoss` + - `details()`: pg-boss specific job detail information. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#getjobbyidid) + - `cancel()`: attempts to cancel a job. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#cancelid) + - `resume()`: attempts to resume a canceled job. [Reference](https://github.com/timgit/pg-boss/blob/8.4.2/docs/readme.md#resumeid) \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/advanced/links.md b/web/versioned_docs/version-0.11.8/advanced/links.md new file mode 100644 index 0000000000..f7510db0d7 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/links.md @@ -0,0 +1,134 @@ +--- +title: Type-Safe Links +--- + +import { Required } from '@site/src/components/Tag' + +If you are using Typescript, you can use Wasp's custom `Link` component to create type-safe links to other pages on your site. + +## Using the `Link` Component + +After you defined a route: + +```wasp title="main.wasp" +route TaskRoute { path: "/task/:id", to: TaskPage } +page TaskPage { ... } +``` + +You can get the benefits of type-safe links by using the `Link` component from `@wasp/router`: + +```jsx title="TaskList.tsx" +import { Link } from '@wasp/router' + +export const TaskList = () => { + // ... + + return ( +
    + {tasks.map((task) => ( + + {/* 👆 All the params must be correctly passed in */} + {task.description} + + ))} +
    + ) +} +``` + +### Using Search Query & Hash + +You can also pass `search` and `hash` props to the `Link` component: + +```tsx title="TaskList.tsx" + + {task.description} + +``` + +This will result in a link like this: `/task/1?sortBy=date#comments`. Check out the [API Reference](#link-component) for more details. + +## The `routes` Object + +You can also get all the pages in your app with the `routes` object: + +```jsx title="TaskList.tsx" +import { routes } from '@wasp/router' + +const linkToTask = routes.TaskRoute.build({ params: { id: 1 } }) +``` + +This will result in a link like this: `/task/1`. + +You can also pass `search` and `hash` props to the `build` function. Check out the [API Reference](#routes-object) for more details. + + +## API Reference + +### `Link` Component + +The `Link` component accepts the following props: +- `to` + + - A valid Wasp Route path from your `main.wasp` file. + +- `params: { [name: string]: string | number }` (if the path contains params) + + - An object with keys and values for each param in the path. + - For example, if the path is `/task/:id`, then the `params` prop must be `{ id: 1 }`. Wasp supports required and optional params. + +- `search: string[][] | Record | string | URLSearchParams` + + - Any valid input for `URLSearchParams` constructor. + - For example, the object `{ sortBy: 'date' }` becomes `?sortBy=date`. + +- `hash: string` +- all other props that the `react-router-dom`'s [Link](https://v5.reactrouter.com/web/api/Link) component accepts + + +### `routes` Object + +The `routes` object contains a function for each route in your app. + +```ts title="router.tsx" +export const routes = { + // RootRoute has a path like "/" + RootRoute: { + build: (options?: { + search?: string[][] | Record | string | URLSearchParams + hash?: string + }) => // ... + }, + + // DetailRoute has a path like "/task/:id/:something?" + DetailRoute: { + build: ( + options: { + params: { id: ParamValue; something?: ParamValue; }, + search?: string[][] | Record | string | URLSearchParams + hash?: string + } + ) => // ... + } +} +``` + +The `params` object is required if the route contains params. The `search` and `hash` parameters are optional. + +You can use the `routes` object like this: + +```tsx +import { routes } from '@wasp/router' + +const linkToRoot = routes.RootRoute.build() +const linkToTask = routes.DetailRoute.build({ params: { id: 1 } }) +``` diff --git a/web/versioned_docs/version-0.11.8/advanced/middleware-config.md b/web/versioned_docs/version-0.11.8/advanced/middleware-config.md new file mode 100644 index 0000000000..859df3d6de --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/middleware-config.md @@ -0,0 +1,280 @@ +--- +title: Configuring Middleware +--- +import { ShowForTs } from '@site/src/components/TsJsHelpers'; + +Wasp comes with a minimal set of useful Express middleware in every application. While this is good for most users, we realize some may wish to add, modify, or remove some of these choices both globally, or on a per-`api`/path basis. + +## Default Global Middleware 🌍 + +Wasp's Express server has the following middleware by default: + +- [Helmet](https://helmetjs.github.io/): Helmet helps you secure your Express apps by setting various HTTP headers. _It's not a silver bullet, but it's a good start._ +- [CORS](https://github.com/expressjs/cors#readme): CORS is a package for providing a middleware that can be used to enable [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) with various options. + + :::note + CORS middleware is required for the frontend to communicate with the backend. + ::: +- [Morgan](https://github.com/expressjs/morgan#readme): HTTP request logger middleware. +- [express.json](https://expressjs.com/en/api.html#express.json) (which uses [body-parser](https://github.com/expressjs/body-parser#bodyparserjsonoptions)): parses incoming request bodies in a middleware before your handlers, making the result available under the `req.body` property. + + :::note + JSON middlware is required for [Operations](../data-model/operations/overview) to function properly. + ::: +- [express.urlencoded](https://expressjs.com/en/api.html#express.urlencoded) (which uses [body-parser](https://expressjs.com/en/resources/middleware/body-parser.html#bodyparserurlencodedoptions)): returns middleware that only parses urlencoded bodies and only looks at requests where the `Content-Type` header matches the type option. +- [cookieParser](https://github.com/expressjs/cookie-parser#readme): parses Cookie header and populates `req.cookies` with an object keyed by the cookie names. + +## Customization + +You have three places where you can customize middleware: +1. [global](#1-customize-global-middleware): here, any changes will apply by default *to all operations (`query` and `action`) and `api`.* This is helpful if you wanted to add support for multiple domains to CORS, for example. + + :::caution Modifying global middleware + Please treat modifications to global middleware with extreme care as they will affect all operations and APIs. If you are unsure, use one of the other two options. + ::: + +2. [per-api](#2-customize-api-specific-middleware): you can override middleware for a specific api route (e.g. `POST /webhook/callback`). This is helpful if you want to disable JSON parsing for some callback, for example. +3. [per-path](#3-customize-per-path-middleware): this is helpful if you need to customize middleware for all methods under a given path. + - It's helpful for things like "complex CORS requests" which may need to apply to both `OPTIONS` and `GET`, or to apply some middleware to a _set of `api` routes_. + +### Default Middleware Definitions + +Below is the actual definitions of default middleware which you can override. + + + + +```js +const defaultGlobalMiddleware = new Map([ + ['helmet', helmet()], + ['cors', cors({ origin: config.allowedCORSOrigins })], + ['logger', logger('dev')], + ['express.json', express.json()], + ['express.urlencoded', express.urlencoded({ extended: false })], + ['cookieParser', cookieParser()] +]) +``` + + + +```ts +export type MiddlewareConfig = Map + +// Used in the examples below 👇 +export type MiddlewareConfigFn = (middlewareConfig: MiddlewareConfig) => MiddlewareConfig + +const defaultGlobalMiddleware: MiddlewareConfig = new Map([ + ['helmet', helmet()], + ['cors', cors({ origin: config.allowedCORSOrigins })], + ['logger', logger('dev')], + ['express.json', express.json()], + ['express.urlencoded', express.urlencoded({ extended: false })], + ['cookieParser', cookieParser()] +]) +``` + + + +## 1. Customize Global Middleware + +If you would like to modify the middleware for _all_ operations and APIs, you can do something like: + + + + + +```wasp {6} title=main.wasp +app todoApp { + // ... + + server: { + setupFn: import setup from "@server/serverSetup.js", + middlewareConfigFn: import { serverMiddlewareFn } from "@server/serverSetup.js" + }, +} +``` + +```ts title=src/server/serverSetup.js +import cors from 'cors' +import config from '@wasp/config.js' + +export const serverMiddlewareFn = (middlewareConfig) => { + // Example of adding extra domains to CORS. + middlewareConfig.set('cors', cors({ origin: [config.frontendUrl, 'https://example1.com', 'https://example2.com'] })) + return middlewareConfig +} +``` + + + + +```wasp {6} title=main.wasp +app todoApp { + // ... + + server: { + setupFn: import setup from "@server/serverSetup.js", + middlewareConfigFn: import { serverMiddlewareFn } from "@server/serverSetup.js" + }, +} +``` + +```ts title=src/server/serverSetup.ts +import cors from 'cors' +import type { MiddlewareConfigFn } from '@wasp/middleware' +import config from '@wasp/config.js' + +export const serverMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => { + // Example of adding an extra domains to CORS. + middlewareConfig.set('cors', cors({ origin: [config.frontendUrl, 'https://example1.com', 'https://example2.com'] })) + return middlewareConfig +} +``` + + + + +## 2. Customize `api`-specific Middleware + +If you would like to modify the middleware for a single API, you can do something like: + + + + +```wasp {5} title=main.wasp +// ... + +api webhookCallback { + fn: import { webhookCallback } from "@server/apis.js", + middlewareConfigFn: import { webhookCallbackMiddlewareFn } from "@server/apis.js", + httpRoute: (POST, "/webhook/callback"), + auth: false +} +``` + +```ts title=src/server/apis.js +import express from 'express' + +export const webhookCallback = (req, res, _context) => { + res.json({ msg: req.body.length }) +} + +export const webhookCallbackMiddlewareFn = (middlewareConfig) => { + console.log('webhookCallbackMiddlewareFn: Swap express.json for express.raw') + + middlewareConfig.delete('express.json') + middlewareConfig.set('express.raw', express.raw({ type: '*/*' })) + + return middlewareConfig +} + +``` + + + +```wasp {5} title=main.wasp +// ... + +api webhookCallback { + fn: import { webhookCallback } from "@server/apis.js", + middlewareConfigFn: import { webhookCallbackMiddlewareFn } from "@server/apis.js", + httpRoute: (POST, "/webhook/callback"), + auth: false +} +``` + +```ts title=src/server/apis.ts +import express from 'express' +import { WebhookCallback } from '@wasp/apis/types' +import type { MiddlewareConfigFn } from '@wasp/middleware' + +export const webhookCallback: WebhookCallback = (req, res, _context) => { + res.json({ msg: req.body.length }) +} + +export const webhookCallbackMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => { + console.log('webhookCallbackMiddlewareFn: Swap express.json for express.raw') + + middlewareConfig.delete('express.json') + middlewareConfig.set('express.raw', express.raw({ type: '*/*' })) + + return middlewareConfig +} + +``` + + + +:::note +This gets installed on a per-method basis. Behind the scenes, this results in code like: + +```js +router.post('/webhook/callback', webhookCallbackMiddleware, ...) +``` +::: + +## 3. Customize Per-Path Middleware + +If you would like to modify the middleware for all API routes under some common path, you can define a `middlewareConfigFn` on an `apiNamespace`: + + + + +```wasp {4} title=main.wasp +// ... + +apiNamespace fooBar { + middlewareConfigFn: import { fooBarNamespaceMiddlewareFn } from "@server/apis.js", + path: "/foo/bar" +} +``` + +```ts title=src/server/apis.js +export const fooBarNamespaceMiddlewareFn = (middlewareConfig) => { + const customMiddleware = (_req, _res, next) => { + console.log('fooBarNamespaceMiddlewareFn: custom middleware') + next() + } + + middlewareConfig.set('custom.middleware', customMiddleware) + + return middlewareConfig +} +``` + + + +```wasp {4} title=main.wasp +// ... + +apiNamespace fooBar { + middlewareConfigFn: import { fooBarNamespaceMiddlewareFn } from "@server/apis.js", + path: "/foo/bar" +} +``` + +```ts title=src/server/apis.ts +import express from 'express' +import type { MiddlewareConfigFn } from '@wasp/middleware' + +export const fooBarNamespaceMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => { + const customMiddleware: express.RequestHandler = (_req, _res, next) => { + console.log('fooBarNamespaceMiddlewareFn: custom middleware') + next() + } + + middlewareConfig.set('custom.middleware', customMiddleware) + + return middlewareConfig +} +``` + + + +:::note +This gets installed at the router level for the path. Behind the scenes, this results in something like: + +```js +router.use('/foo/bar', fooBarNamespaceMiddleware) +``` +::: diff --git a/web/versioned_docs/version-0.11.8/advanced/web-sockets.md b/web/versioned_docs/version-0.11.8/advanced/web-sockets.md new file mode 100644 index 0000000000..de4032a488 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/advanced/web-sockets.md @@ -0,0 +1,335 @@ +--- +title: Web Sockets +--- +import useBaseUrl from '@docusaurus/useBaseUrl'; +import { ShowForTs } from '@site/src/components/TsJsHelpers'; +import { Required } from '@site/src/components/Tag'; + +Wasp provides a fully integrated WebSocket experience by utilizing [Socket.IO](https://socket.io/) on the client and server. + +We handle making sure your URLs are correctly setup, CORS is enabled, and provide a useful `useSocket` and `useSocketListener` abstractions for use in React components. + +To get started, you need to: +1. Define your WebSocket logic on the server. +2. Enable WebSockets in your Wasp file, and connect it with your server logic. +3. Use WebSockets on the client, in React, via `useSocket` and `useSocketListener`. +4. Optionally, type the WebSocket events and payloads for full-stack type safety. + +Let's go through setting up WebSockets step by step, starting with enabling WebSockets in your Wasp file. + +## Turn On WebSockets in Your Wasp File +We specify that we are using WebSockets by adding `webSocket` to our `app` and providing the required `fn`. You can optionally change the auto-connect behavior. + + + + +```wasp title=todoApp.wasp +app todoApp { + // ... + + webSocket: { + fn: import { webSocketFn } from "@server/webSocket.js", + autoConnect: true, // optional, default: true + }, +} +``` + + + +```wasp title=todoApp.wasp +app todoApp { + // ... + + webSocket: { + fn: import { webSocketFn } from "@server/webSocket.js", + autoConnect: true, // optional, default: true + }, +} +``` + + + +## Defining the Events Handler +Let's define the WebSockets server with all of the events and handler functions. + + + +:::info Full-stack type safety +Check this out: we'll define the event types and payloads on the server, and they will be **automatically exposed on the client**. This helps you avoid mistakes when emitting events or handling them. +::: + + +### `webSocketFn` Function +On the server, you will get Socket.IO `io: Server` argument and `context` for your WebSocket function. The `context` object give you access to all of the entities from your Wasp app. + +You can use this `io` object to register callbacks for all the regular [Socket.IO events](https://socket.io/docs/v4/server-api/). Also, if a user is logged in, you will have a `socket.data.user` on the server. + +This is how we can define our `webSocketFn` function: + + + + +```ts title=src/server/webSocket.js +import { v4 as uuidv4 } from 'uuid' + +export const webSocketFn = (io, context) => { + io.on('connection', (socket) => { + const username = socket.data.user?.email || socket.data.user?.username || 'unknown' + console.log('a user connected: ', username) + + socket.on('chatMessage', async (msg) => { + console.log('message: ', msg) + io.emit('chatMessage', { id: uuidv4(), username, text: msg }) + // You can also use your entities here: + // await context.entities.SomeEntity.create({ someField: msg }) + }) + }) +} +``` + + + +```ts title=src/server/webSocket.ts +import type { WebSocketDefinition, WaspSocketData } from '@wasp/webSocket' +import { v4 as uuidv4 } from 'uuid' + +export const webSocketFn: WebSocketFn = (io, context) => { + io.on('connection', (socket) => { + const username = socket.data.user?.email || socket.data.user?.username || 'unknown' + console.log('a user connected: ', username) + + socket.on('chatMessage', async (msg) => { + console.log('message: ', msg) + io.emit('chatMessage', { id: uuidv4(), username, text: msg }) + // You can also use your entities here: + // await context.entities.SomeEntity.create({ someField: msg }) + }) + }) +} + +// Typing our WebSocket function with the events and payloads +// allows us to get type safety on the client as well + +type WebSocketFn = WebSocketDefinition< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData +> + +interface ServerToClientEvents { + chatMessage: (msg: { id: string, username: string, text: string }) => void; +} + +interface ClientToServerEvents { + chatMessage: (msg: string) => void; +} + +interface InterServerEvents {} + +// Data that is attached to the socket. +// NOTE: Wasp automatically injects the JWT into the connection, +// and if present/valid, the server adds a user to the socket. +interface SocketData extends WaspSocketData {} +``` + + + +## Using the WebSocket On The Client + + + +:::info Full-stack type safety +All the hooks we use are typed with the events and payloads you defined on the server. VS Code will give you autocomplete for the events and payloads, and you will get type errors if you make a mistake. +::: + + +### `useSocket` Hook + +Client access to WebSockets is provided by the `useSocket` hook. It returns: +- `socket: Socket` for sending and receiving events. +- `isConnected: boolean` for showing a display of the Socket.IO connection status. + - Note: Wasp automatically connects and establishes a WebSocket connection from the client to the server by default, so you do not need to explicitly `socket.connect()` or `socket.disconnect()`. + - If you set `autoConnect: false` in your Wasp file, then you should call these as needed. + +All components using `useSocket` share the same underlying `socket`. + +### `useSocketListener` Hook + +Additionally, there is a `useSocketListener: (event, callback) => void` hook which is used for registering event handlers. It takes care of unregistering the handler on unmount. + + + + + +```tsx title=src/client/ChatPage.jsx +import React, { useState } from 'react' +import { + useSocket, + useSocketListener, +} from '@wasp/webSocket' + +export const ChatPage = () => { + const [messageText, setMessageText] = useState('') + const [messages, setMessages] = useState([]) + const { socket, isConnected } = useSocket() + + useSocketListener('chatMessage', logMessage) + + function logMessage(msg) { + setMessages((priorMessages) => [msg, ...priorMessages]) + } + + function handleSubmit(e) { + e.preventDefault() + socket.emit('chatMessage', messageText) + setMessageText('') + } + + const messageList = messages.map((msg) => ( +
  • + {msg.username}: {msg.text} +
  • + )) + const connectionIcon = isConnected ? '🟢' : '🔴' + + return ( + <> +

    Chat {connectionIcon}

    +
    +
    +
    +
    + setMessageText(e.target.value)} + /> +
    +
    + +
    +
    +
    +
      {messageList}
    +
    + + ) +} +``` +
    + + +Wasp's **full-stack type safety** kicks in here: all the event types and payloads are automatically inferred from the server and are available on the client 🔥 + +You can additionally use the `ClientToServerPayload` and `ServerToClientPayload` helper types to get the payload type for a specific event. + +```tsx title=src/client/ChatPage.tsx +import React, { useState } from 'react' +import { + useSocket, + useSocketListener, + ServerToClientPayload, +} from '@wasp/webSocket' + +export const ChatPage = () => { + const [messageText, setMessageText] = useState< + // We are using a helper type to get the payload type for the "chatMessage" event. + ClientToServerPayload<'chatMessage'> + >('') + const [messages, setMessages] = useState< + ServerToClientPayload<'chatMessage'>[] + >([]) + // The "socket" instance is typed with the types you defined on the server. + const { socket, isConnected } = useSocket() + + // This is a type-safe event handler: "chatMessage" event and its payload type + // are defined on the server. + useSocketListener('chatMessage', logMessage) + + function logMessage(msg: ServerToClientPayload<'chatMessage'>) { + setMessages((priorMessages) => [msg, ...priorMessages]) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + // This is a type-safe event emitter: "chatMessage" event and its payload type + // are defined on the server. + socket.emit('chatMessage', messageText) + setMessageText('') + } + + const messageList = messages.map((msg) => ( +
  • + {msg.username}: {msg.text} +
  • + )) + const connectionIcon = isConnected ? '🟢' : '🔴' + + return ( + <> +

    Chat {connectionIcon}

    +
    +
    +
    +
    + setMessageText(e.target.value)} + /> +
    +
    + +
    +
    +
    +
      {messageList}
    +
    + + ) +} +``` +
    +
    + +## API Reference + + + + +```wasp title=todoApp.wasp +app todoApp { + // ... + + webSocket: { + fn: import { webSocketFn } from "@server/webSocket.js", + autoConnect: true, // optional, default: true + }, +} +``` + + + +```wasp title=todoApp.wasp +app todoApp { + // ... + + webSocket: { + fn: import { webSocketFn } from "@server/webSocket.js", + autoConnect: true, // optional, default: true + }, +} +``` + + + +The `webSocket` dict has the following fields: + +- `fn: WebSocketFn` + + The function that defines the WebSocket events and handlers. + +- `autoConnect: bool` + + Whether to automatically connect to the WebSocket server. Default: `true`. \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/auth/Pills.css b/web/versioned_docs/version-0.11.8/auth/Pills.css new file mode 100644 index 0000000000..6568744fba --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/Pills.css @@ -0,0 +1,15 @@ +:root { + --auth-pills-color: #333; + --auth-pills-email: #e0f2fe; + --auth-pills-github: #f1f5f9; + --auth-pills-google: #ecfccb; + --auth-pills-username-and-pass: #fce7f3; +} + +:root[data-theme="dark"] { + --auth-pills-color: #fff; + --auth-pills-email: #0c4a6e; + --auth-pills-github: #334155; + --auth-pills-google: #365314; + --auth-pills-username-and-pass: #831843; +} \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/auth/Pills.jsx b/web/versioned_docs/version-0.11.8/auth/Pills.jsx new file mode 100644 index 0000000000..ef148337a4 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/Pills.jsx @@ -0,0 +1,48 @@ +import React from "react"; +import './Pills.css'; +import Link from '@docusaurus/Link'; + +export function Pill({ children, linkToPage, style = {} }) { + return {children}; +} + +/* +:root { + --auth-pills-email: #e0f2fe; + --auth-pills-github: #f1f5f9; + --auth-pills-google: #ecfccb; + --auth-pills-username-and-pass: #fce7f3; +} +*/ +export function EmailPill() { + return Email; +} + +export function UsernameAndPasswordPill() { + return Username & Password; +} + +export function GithubPill() { + return Github; +} + +export function GooglePill() { + return Google; +} diff --git a/web/versioned_docs/version-0.11.8/auth/email.md b/web/versioned_docs/version-0.11.8/auth/email.md new file mode 100644 index 0000000000..eec4e59fa3 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/email.md @@ -0,0 +1,914 @@ +--- +title: Email +--- + +import { Required } from '@site/src/components/Tag'; + +Wasp supports e-mail authentication out of the box, along with email verification and "forgot your password?" flows. It provides you with the server-side implementation and email templates for all of these flows. + +![Auth UI](/img/authui/all_screens.gif) + +:::caution Using email auth and social auth together +If a user signs up with Google or Github (and you set it up to save their social provider e-mail info on the `User` entity), they'll be able to reset their password and login with e-mail and password ✅ + +If a user signs up with the e-mail and password and then tries to login with a social provider (Google or Github), they won't be able to do that ❌ + +In the future, we will lift this limitation and enable smarter merging of accounts. +::: + +## Setting Up Email Authentication + +We'll need to take the following steps to set up email authentication: +1. Enable email authentication in the Wasp file +1. Add the user entity +1. Add the routes and pages +1. Use Auth UI components in our pages +1. Set up the email sender + +Structure of the `main.wasp` file we will end up with: + +```wasp title="main.wasp" +// Configuring e-mail authentication +app myApp { + auth: { ... } +} + +// Defining User entity +entity User { ... } + +// Defining routes and pages +route SignupRoute { ... } +page SignupPage { ... } +// ... +``` + +### 1. Enable Email Authentication in `main.wasp` + +Let's start with adding the following to our `main.wasp` file: + + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // 1. Specify the user entity (we'll define it next) + userEntity: User, + methods: { + // 2. Enable email authentication + email: { + // 3. Specify the email from field + fromField: { + name: "My App Postman", + email: "hello@itsme.com" + }, + // 4. Specify the email verification and password reset options (we'll talk about them later) + emailVerification: { + clientRoute: EmailVerificationRoute, + }, + passwordReset: { + clientRoute: PasswordResetRoute, + }, + allowUnverifiedLogin: false, + }, + }, + onAuthFailedRedirectTo: "/login", + onAuthSucceededRedirectTo: "/" + }, +} +``` + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // 1. Specify the user entity (we'll define it next) + userEntity: User, + methods: { + // 2. Enable email authentication + email: { + // 3. Specify the email from field + fromField: { + name: "My App Postman", + email: "hello@itsme.com" + }, + // 4. Specify the email verification and password reset options (we'll talk about them later) + emailVerification: { + clientRoute: EmailVerificationRoute, + }, + passwordReset: { + clientRoute: PasswordResetRoute, + }, + allowUnverifiedLogin: false, + }, + }, + onAuthFailedRedirectTo: "/login", + onAuthSucceededRedirectTo: "/" + }, +} +``` + + + +Read more about the `email` auth method options [here](#fields-in-the-email-dict). + +### 2. Add the User Entity + +When email authentication is enabled, Wasp expects certain fields in your `userEntity`. Let's add these fields to our `main.wasp` file: + + + + +```wasp title="main.wasp" {4-8} +// 5. Define the user entity +entity User {=psl + id Int @id @default(autoincrement()) + email String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? + // Add your own fields below + // ... +psl=} +``` + + + +```wasp title="main.wasp" {4-8} +// 5. Define the user entity +entity User {=psl + id Int @id @default(autoincrement()) + email String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? + // Add your own fields below + // ... +psl=} +``` + + + +Read more about the `userEntity` fields [here](#userentity-fields). + +### 3. Add the Routes and Pages + +Next, we need to define the routes and pages for the authentication pages. + +Add the following to the `main.wasp` file: + + + + +```wasp title="main.wasp" +// ... + +// 6. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.jsx" +} + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { Signup } from "@client/pages/auth.jsx" +} + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } +page RequestPasswordResetPage { + component: import { RequestPasswordReset } from "@client/pages/auth.jsx", +} + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { PasswordReset } from "@client/pages/auth.jsx", +} + +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { EmailVerification } from "@client/pages/auth.jsx", +} +``` + + + +```wasp title="main.wasp" +// ... + +// 6. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.tsx" +} + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { Signup } from "@client/pages/auth.tsx" +} + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } +page RequestPasswordResetPage { + component: import { RequestPasswordReset } from "@client/pages/auth.tsx", +} + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { PasswordReset } from "@client/pages/auth.tsx", +} + +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { EmailVerification } from "@client/pages/auth.tsx", +} +``` + + + +We'll define the React components for these pages in the `client/pages/auth.{jsx,tsx}` file below. + +### 4. Create the Client Pages + +:::info +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../project/css-frameworks). +::: + +Let's create a `auth.{jsx,tsx}` file in the `client/pages` folder and add the following to it: + + + + +```tsx title="client/pages/auth.jsx" +import { LoginForm } from "@wasp/auth/forms/Login"; +import { SignupForm } from "@wasp/auth/forms/Signup"; +import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; +import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; +import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; +import { Link } from "react-router-dom"; + +export function Login() { + return ( + + +
    + + Don't have an account yet? go to signup. + +
    + + Forgot your password? reset it + . + +
    + ); +} + +export function Signup() { + return ( + + +
    + + I already have an account (go to login). + +
    + ); +} + +export function EmailVerification() { + return ( + + +
    + + If everything is okay, go to login + +
    + ); +} + +export function RequestPasswordReset() { + return ( + + + + ); +} + +export function PasswordReset() { + return ( + + +
    + + If everything is okay, go to login + +
    + ); +} + +// A layout component to center the content +export function Layout({ children }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ); +} +``` +
    + + +```tsx title="client/pages/auth.tsx" +import { LoginForm } from "@wasp/auth/forms/Login"; +import { SignupForm } from "@wasp/auth/forms/Signup"; +import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; +import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; +import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; +import { Link } from "react-router-dom"; + +export function Login() { + return ( + + +
    + + Don't have an account yet? go to signup. + +
    + + Forgot your password? reset it + . + +
    + ); +} + +export function Signup() { + return ( + + +
    + + I already have an account (go to login). + +
    + ); +} + +export function EmailVerification() { + return ( + + +
    + + If everything is okay, go to login + +
    + ); +} + +export function RequestPasswordReset() { + return ( + + + + ); +} + +export function PasswordReset() { + return ( + + +
    + + If everything is okay, go to login + +
    + ); +} + +// A layout component to center the content +export function Layout({ children }: { children: React.ReactNode }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ); +} +``` +
    +
    + +We imported the generated Auth UI components and used them in our pages. Read more about the Auth UI components [here](../auth/ui). + +### 5. Set up an Email Sender + +To support e-mail verification and password reset flows, we need an e-mail sender. Luckily, Wasp supports several email providers out of the box. + +We'll use SendGrid in this guide to send our e-mails. You can use any of the supported email providers. + +To set up SendGrid to send emails, we will add the following to our `main.wasp` file: + + + + +```wasp title="main.wasp" +app myApp { + // ... + // 7. Set up the email sender + emailSender: { + provider: SendGrid, + } +} +``` + + + +```wasp title="main.wasp" +app myApp { + // ... + // 7. Set up the email sender + emailSender: { + provider: SendGrid, + } +} +``` + + + +... and add the following to our `.env.server` file: + +```c title=".env.server" +SENDGRID_API_KEY= +``` + +If you are not sure how to get a SendGrid API key, read more [here](../advanced/email#getting-the-api-key). + +Read more about setting up email senders in the [sending emails docs](../advanced/email). + +### Conclusion + +That's it! We have set up email authentication in our app. 🎉 + +Running `wasp db migrate-dev` and then `wasp start` should give you a working app with email authentication. If you want to put some of the pages behind authentication, read the [using auth docs](../auth/overview). + +## Login and Signup Flows + +### Login + +![Auth UI](/img/authui/login.png) + +If logging in with an unverified email is _allowed_, the user will be able to login with an unverified email address. If logging in with an unverified email is _not allowed_, the user will be shown an error message. + +Read more about the `allowUnverifiedLogin` option [here](#allowunverifiedlogin-bool-specifies-whether-the-user-can-login-without-verifying-their-e-mail-address). + +### Signup + +![Auth UI](/img/authui/signup.png) + +Some of the behavior you get out of the box: +1. Rate limiting + + We are limiting the rate of sign-up requests to **1 request per minute** per email address. This is done to prevent spamming. + +2. Preventing user email leaks + + If somebody tries to signup with an email that already exists and it's verified, we _pretend_ that the account was created instead of saying it's an existing account. This is done to prevent leaking the user's email address. + +3. Allowing registration for unverified emails + + If a user tries to register with an existing but **unverified** email, we'll allow them to do that. This is done to prevent bad actors from locking out other users from registering with their email address. + +4. Password validation + + Read more about the default password validation rules and how to override them in [using auth docs](../auth/overview). + +## Email Verification Flow + +By default, Wasp requires the e-mail to be verified before allowing the user to log in. This is done by sending a verification email to the user's email address and requiring the user to click on a link in the email to verify their email address. + +Our setup looks like this: + + + + +```wasp title="main.wasp" +// ... + +emailVerification: { + clientRoute: EmailVerificationRoute, +} +``` + + + +```wasp title="main.wasp" +// ... + +emailVerification: { + clientRoute: EmailVerificationRoute, +} +``` + + + +When the user receives an e-mail, they receive a link that goes to the client route specified in the `clientRoute` field. In our case, this is the `EmailVerificationRoute` route we defined in the `main.wasp` file. + +The content of the e-mail can be customized, read more about it [here](#emailverification-emailverificationconfig-). + +### Email Verification Page + +We defined our email verification page in the `auth.{jsx,tsx}` file. + +![Auth UI](/img/authui/email_verification.png) + +## Password Reset Flow + +Users can request a password and then they'll receive an e-mail with a link to reset their password. + +Some of the behavior you get out of the box: +1. Rate limiting + + We are limiting the rate of sign-up requests to **1 request per minute** per email address. This is done to prevent spamming. + +2. Preventing user email leaks + + If somebody requests a password reset with an unknown email address, we'll give back the same response as if the user requested a password reset successfully. This is done to prevent leaking information. + +Our setup in `main.wasp` looks like this: + + + + +```wasp title="main.wasp" +// ... + +passwordReset: { + clientRoute: PasswordResetRoute, +} +``` + + + +```wasp title="main.wasp" +// ... + +passwordReset: { + clientRoute: PasswordResetRoute, +} +``` + + + +### Request Password Reset Page + +Users request their password to be reset by going to the `/request-password-reset` route. We defined our request password reset page in the `auth.{jsx,tsx}` file. + +![Request password reset page](/img/authui/forgot_password_after.png) + +### Password Reset Page + +When the user receives an e-mail, they receive a link that goes to the client route specified in the `clientRoute` field. In our case, this is the `PasswordResetRoute` route we defined in the `main.wasp` file. + +![Request password reset page](/img/authui/reset_password_after.png) + +Users can enter their new password there. + +The content of the e-mail can be customized, read more about it [here](#passwordreset-passwordresetconfig-). + +## Using The Auth + +To read more about how to set up the logout button and how to get access to the logged-in user in our client and server code, read the [using auth docs](../auth/overview). + +## API Reference + +Let's go over the options we can specify when using email authentication. + +### `userEntity` fields + + + + +```wasp title="main.wasp" {18-25} +app myApp { + title: "My app", + // ... + + auth: { + userEntity: User, + methods: { + email: { + // We'll explain these options below + }, + }, + onAuthFailedRedirectTo: "/someRoute" + }, + // ... +} + +// Using email auth requires the `userEntity` to have at least the following fields +entity User {=psl + id Int @id @default(autoincrement()) + email String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? +psl=} +``` + + + +```wasp title="main.wasp" {18-25} +app myApp { + title: "My app", + // ... + + auth: { + userEntity: User, + methods: { + email: { + // We'll explain these options below + }, + }, + onAuthFailedRedirectTo: "/someRoute" + }, + // ... +} + +// Using email auth requires the `userEntity` to have at least the following fields +entity User {=psl + id Int @id @default(autoincrement()) + email String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? +psl=} +``` + + + +Email auth requires that `userEntity` specified in `auth` contains: + +- optional `email` field of type `String` +- optional `password` field of type `String` +- `isEmailVerified` field of type `Boolean` with a default value of `false` +- optional `emailVerificationSentAt` field of type `DateTime` +- optional `passwordResetSentAt` field of type `DateTime` + +### Fields in the `email` dict + + + + +```wasp title="main.wasp" +app myApp { + title: "My app", + // ... + + auth: { + userEntity: User, + methods: { + email: { + fromField: { + name: "My App", + email: "hello@itsme.com" + }, + emailVerification: { + clientRoute: EmailVerificationRoute, + getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js", + }, + passwordReset: { + clientRoute: PasswordResetRoute, + getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", + }, + allowUnverifiedLogin: false, + }, + }, + onAuthFailedRedirectTo: "/someRoute" + }, + // ... +} +``` + + + +```wasp title="main.wasp" +app myApp { + title: "My app", + // ... + + auth: { + userEntity: User, + methods: { + email: { + fromField: { + name: "My App", + email: "hello@itsme.com" + }, + emailVerification: { + clientRoute: EmailVerificationRoute, + getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js", + }, + passwordReset: { + clientRoute: PasswordResetRoute, + getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js", + }, + allowUnverifiedLogin: false, + }, + }, + onAuthFailedRedirectTo: "/someRoute" + }, + // ... +} +``` + + + +#### `fromField: EmailFromField` +`fromField` is a dict that specifies the name and e-mail address of the sender of the e-mails sent by your app. + +It has the following fields: +- `name`: name of the sender +- `email`: e-mail address of the sender + +#### `emailVerification: EmailVerificationConfig` +`emailVerification` is a dict that specifies the details of the e-mail verification process. + +It has the following fields: +- `clientRoute: Route`: a route that is used for the user to verify their e-mail address. + + Client route should handle the process of taking a token from the URL and sending it to the server to verify the e-mail address. You can use our `verifyEmail` action for that. + + + + + ```js title="src/pages/EmailVerificationPage.jsx" + import { verifyEmail } from '@wasp/auth/email/actions'; + ... + await verifyEmail({ token }); + ``` + + + + ```ts title="src/pages/EmailVerificationPage.tsx" + import { verifyEmail } from '@wasp/auth/email/actions'; + ... + await verifyEmail({ token }); + ``` + + + + :::note + We used Auth UI above to avoid doing this work of sending the token to the server manually. + ::: + +- `getEmailContentFn: ServerImport`: a function that returns the content of the e-mail that is sent to the user. + + Defining `getEmailContentFn` can be done by defining a file in the `server` directory. + + + + + ```ts title="server/email.js" + export const getVerificationEmailContent = ({ verificationLink }) => ({ + subject: 'Verify your email', + text: `Click the link below to verify your email: ${verificationLink}`, + html: ` +

    Click the link below to verify your email

    + Verify email + `, + }) + ``` +
    + + + ```ts title="server/email.ts" + import { GetVerificationEmailContentFn } from '@wasp/types' + + export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ + verificationLink, + }) => ({ + subject: 'Verify your email', + text: `Click the link below to verify your email: ${verificationLink}`, + html: ` +

    Click the link below to verify your email

    + Verify email + `, + }) + ``` +
    +
    + + This is the default content of the e-mail, you can customize it to your liking. + + +#### `passwordReset: PasswordResetConfig` +`passwordReset` is a dict that specifies the password reset process. + +It has the following fields: +- `clientRoute: Route`: a route that is used for the user to reset their password. + + Client route should handle the process of taking a token from the URL and a new password from the user and sending it to the server. You can use our `requestPasswordReset` and `resetPassword` actions to do that. + + + + + ```js title="src/pages/ForgotPasswordPage.jsx" + import { requestPasswordReset } from '@wasp/auth/email/actions'; + ... + await requestPasswordReset({ email }); + ``` + + ```js title="src/pages/PasswordResetPage.jsx" + import { resetPassword } from '@wasp/auth/email/actions'; + ... + await resetPassword({ password, token }) + ``` + + + + ```ts title="src/pages/ForgotPasswordPage.tsx" + import { requestPasswordReset } from '@wasp/auth/email/actions'; + ... + await requestPasswordReset({ email }); + ``` + + ```ts title="src/pages/PasswordResetPage.tsx" + import { resetPassword } from '@wasp/auth/email/actions'; + ... + await resetPassword({ password, token }) + ``` + + + + :::note + We used Auth UI above to avoid doing this work of sending the password request and the new password to the server manually. + ::: + +- `getEmailContentFn: ServerImport`: a function that returns the content of the e-mail that is sent to the user. + + Defining `getEmailContentFn` is done by defining a function that looks like this: + + + + + ```ts title="server/email.js" + export const getPasswordResetEmailContent = ({ passwordResetLink }) => ({ + subject: 'Password reset', + text: `Click the link below to reset your password: ${passwordResetLink}`, + html: ` +

    Click the link below to reset your password

    + Reset password + `, + }) + ``` +
    + + + ```ts title="server/email.ts" + import { GetPasswordResetEmailContentFn } from '@wasp/types' + + export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ + passwordResetLink, + }) => ({ + subject: 'Password reset', + text: `Click the link below to reset your password: ${passwordResetLink}`, + html: ` +

    Click the link below to reset your password

    + Reset password + `, + }) + ``` +
    +
    + + This is the default content of the e-mail, you can customize it to your liking. + +#### `allowUnverifiedLogin: bool`: specifies whether the user can login without verifying their e-mail address + +It defaults to `false`. If `allowUnverifiedLogin` is set to `true`, the user can login without verifying their e-mail address, otherwise users will receive a `401` error when trying to login without verifying their e-mail address. + +Sometimes you want to allow unverified users to login to provide them a different onboarding experience. Some of the pages can be viewed without verifying the e-mail address, but some of them can't. You can use the `isEmailVerified` field on the user entity to check if the user has verified their e-mail address. + +If you have any questions, feel free to ask them on [our Discord server](https://discord.gg/rzdnErX). \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/auth/overview.md b/web/versioned_docs/version-0.11.8/auth/overview.md new file mode 100644 index 0000000000..364453e351 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/overview.md @@ -0,0 +1,1306 @@ +--- +title: Using Auth +--- + +import { AuthMethodsGrid } from "@site/src/components/AuthMethodsGrid"; +import { Required } from "@site/src/components/Tag"; + +Auth is an essential piece of any serious application. Coincidentally, Wasp provides authentication and authorization support out of the box. + +Here's a 1-minute tour of how full-stack auth works in Wasp: + +
    + +
    + +Enabling auth for your app is optional and can be done by configuring the `auth` field of the `app` declaration. + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + //... + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + usernameAndPassword: {}, // use this or email, not both + email: {}, // use this or usernameAndPassword, not both + google: {}, + gitHub: {}, + }, + onAuthFailedRedirectTo: "/someRoute" + } +} + +//... +``` + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + //... + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + usernameAndPassword: {}, // use this or email, not both + email: {}, // use this or usernameAndPassword, not both + google: {}, + gitHub: {}, + }, + onAuthFailedRedirectTo: "/someRoute" + } +} + +//... +``` + + + + + + +Read more about the `auth` field options in the [API Reference](#api-reference) section. + + + +We will provide a quick overview of auth in Wasp and link to more detailed documentation for each auth method. + +## Available auth methods + +Wasp supports the following auth methods: + + + +Let's say we enabled the [Username & password](../auth/username-and-pass) authentication. + +We get an auth backend with signup and login endpoints. We also get the `user` object in our [Operations](../data-model/operations/overview) and we can decide what to do based on whether the user is logged in or not. + +We would also get the [Auth UI](../auth/ui) generated for us. We can set up our login and signup pages where our users can **create their account** and **login**. We can then protect certain pages by setting `authRequired: true` for them. This will make sure that only logged-in users can access them. + +We will also have access to the `user` object in our frontend code, so we can show different UI to logged-in and logged-out users. For example, we can show the user's name in the header alongside a **logout button** or a login button if the user is not logged in. + +## Protecting a page with `authRequired` + +When declaring a page, you can set the `authRequired` property. + +If you set it to `true`, only authenticated users can access the page. Unauthenticated users are redirected to a route defined by the `app.auth.onAuthFailedRedirectTo` field. + + + + +```wasp title="main.wasp" +page MainPage { + component: import Main from "@client/pages/Main.jsx", + authRequired: true +} +``` + + + + +```wasp title="main.wasp" +page MainPage { + component: import Main from "@client/pages/Main.tsx", + authRequired: true +} +``` + + + + +:::caution Requires auth method +You can only use `authRequired` if your app uses one of the [available auth methods](#available-auth-methods). +::: + +If `authRequired` is set to `true`, the page's React component (specified by the `component` property) receives the `user` object as a prop. Read more about the `user` object in the [Accessing the logged-in user section](#accessing-the-logged-in-user). + +## Logout action + +We provide an action for logging out the user. Here's how you can use it: + + + + +```jsx title="client/components/LogoutButton.jsx" +import logout from '@wasp/auth/logout' + +const LogoutButton = () => { + return +} +``` + + + + +```tsx title="client/components/LogoutButton.tsx" +import logout from '@wasp/auth/logout' + +const LogoutButton = () => { + return +} +``` + + + + +## Accessing the logged-in user + +You can get access to the `user` object both in the backend and on the frontend. + +### On the client + +There are two ways to access the `user` object on the client: + +- the `user` prop +- the `useAuth` hook + +#### Using the `user` prop + +If the page's declaration sets `authRequired` to `true`, the page's React component receives the `user` object as a prop: + + + + +```wasp title="main.wasp" +// ... + +page AccountPage { + component: import Account from "@client/pages/Account.jsx", + authRequired: true +} +``` + +```jsx title="client/pages/Account.jsx" +import Button from './Button' +import logout from '@wasp/auth/logout' + +const AccountPage = ({ user }) => { + return ( +
    + + {JSON.stringify(user, null, 2)} +
    + ) +} + +export default AccountPage +``` + +
    + + +```wasp title="main.wasp" +// ... + +page AccountPage { + component: import Account from "@client/pages/Account.tsx", + authRequired: true +} +``` + +```tsx title="client/pages/Account.tsx" +import type { User } from '@wasp/entities' +import Button from './Button' +import logout from '@wasp/auth/logout' + +const AccountPage = ({ user }: { user: User }) => { + return ( +
    + + {JSON.stringify(user, null, 2)} +
    + ) +} + +export default AccountPage +``` + +
    +
    + +#### Using the `useAuth` hook + +Wasp provides a React hook you can use in the client components - `useAuth`. + +This hook is a thin wrapper over Wasp's `useQuery` hook and returns data in the same format. + + + + +```jsx title="src/client/pages/MainPage.jsx" +import useAuth from '@wasp/auth/useAuth' +import { Link } from 'react-router-dom' +import logout from '@wasp/auth/logout' +import Todo from '../Todo' + +export function Main() { + const { data: user } = useAuth() + + if (!user) { + return ( + + Please login or{' '} + sign up. + + ) + } else { + return ( + <> + + + + ) + } +} +``` + + + + +```tsx title="src/client/pages/MainPage.tsx" +import useAuth from '@wasp/auth/useAuth' +import { Link } from 'react-router-dom' +import logout from '@wasp/auth/logout' +import Todo from '../Todo' + +export function Main() { + const { data: user } = useAuth() + + if (!user) { + return ( + + Please login or sign up. + + ) + } else { + return ( + <> + + + < /> + ) + } +} +``` + + + + +:::tip +Since the `user` prop is only available in a page's React component: use the `user` prop in the page's React component and the `useAuth` hook in any other React component. +::: + +### On the server + +#### Using the `context.user` object + +When authentication is enabled, all [queries and actions](../data-model/operations/overview) have access to the `user` object through the `context` argument. `context.user` contains all User entity's fields, except for the password. + + + + +```js title="src/server/actions.js" +import HttpError from '@wasp/core/HttpError.js' + +export const createTask = async (task, context) => { + if (!context.user) { + throw new HttpError(403) + } + + const Task = context.entities.Task + return Task.create({ + data: { + description: task.description, + user: { + connect: { id: context.user.id }, + }, + }, + }) +} +``` + + + + +```ts title="src/server/actions.ts" +import type { Task } from '@wasp/entities' +import type { CreateTask } from '@wasp/actions/types' +import HttpError from '@wasp/core/HttpError.js' + +type CreateTaskPayload = Pick + +export const createTask: CreateTask = async ( + args, + context +) => { + if (!context.user) { + throw new HttpError(403) + } + + const Task = context.entities.Task + return Task.create({ + data: { + description: args.description, + user: { + connect: { id: context.user.id }, + }, + }, + }) +} +``` + + + + +To implement access control in your app, each operation must check `context.user` and decide what to do. For example, if `context.user` is `undefined` inside a private operation, the user's access should be denied. + +When using WebSockets, the `user` object is also available on the `socket.data` object. Read more in the [WebSockets section](../advanced/web-sockets#websocketfn-function). + +## User entity + +### Password hashing + +You don't need to worry about hashing the password yourself. Even when directly using the Prisma client and calling `create()` with a plain-text password, Wasp's middleware makes sure to hash the password before storing it in the database. +For example, if you need to update a user's password, you can safely use the Prisma client to do so, e.g., inside an Action: + + + + +```js title="src/server/actions.js" +export const updatePassword = async (args, context) => { + return context.entities.User.update({ + where: { id: args.userId }, + data: { + password: 'New pwd which will be hashed automatically!', + }, + }) +} +``` + + + + +```ts title="src/server/actions.ts" +import type { UpdatePassword } from '@wasp/actions/types' +import type { User } from '@wasp/entities' + +type UpdatePasswordPayload = { + userId: User['id'] +} + +export const updatePassword: UpdatePassword< + UpdatePasswordPayload, + User +> = async (args, context) => { + return context.entities.User.update({ + where: { id: args.userId }, + data: { + password: 'New pwd which will be hashed automatically!', + }, + }) +} +``` + + + + +### Default validations + +Wasp includes several basic validation mechanisms. If you need something extra, the [next section](#customizing-validations) shows how to customize them. + +Default validations depend on the auth method you use. + +#### Username & password + +If you use [Username & password](../auth/username-and-pass) authentication, the default validations are: + +- The `username` must not be empty +- The `password` must not be empty, have at least 8 characters, and contain a number + +Note that `username`s are stored in a **case-sensitive** manner. + +#### Email + +If you use [Email](../auth/email) authentication, the default validations are: + +- The `email` must not be empty and a valid email address +- The `password` must not be empty, have at least 8 characters, and contain a number + +Note that `email`s are stored in a **case-insensitive** manner. + +### Customizing validations + +:::note +You can only disable the default validation for **Username & password** authentication, but you can add custom validations can to both **Username & password** and **Email** auth methods. + +This is a bug in Wasp that is being tracked [here](https://github.com/wasp-lang/wasp/issues/1358) +::: + +To disable/enable default validations, or add your own, modify your custom signup function: + + + + +```js +const newUser = context.entities.User.create({ + data: { + username: args.username, + password: args.password, // password hashed automatically by Wasp! 🐝 + }, + _waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true + _waspCustomValidations: [ + { + validates: 'password', + message: 'password must contain an uppercase letter', + validator: (password) => /[A-Z]/.test(password), + }, + ], +}) +``` + + + + +```ts +const newUser = context.entities.User.create({ + data: { + username: args.username, + password: args.password, // password hashed automatically by Wasp! 🐝 + }, + _waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true + _waspCustomValidations: [ + { + validates: 'password', + message: 'password must contain an uppercase letter', + validator: (password) => /[A-Z]/.test(password), + }, + ], +}) +``` + + + + +:::info +Validations always run on `create()`. +For `update()`, they only run when the field mentioned in `validates` is present. + +The validation process stops on the first `validator` to return false. If enabled, default validations run first and then custom validations. +::: + +### Validation Error Handling + +When creating, updating, or deleting entities, you may wish to handle validation errors. Wasp exposes a class called `AuthError` for this purpose. + + + + +```js title="src/server/actions.js" +try { + await context.entities.User.update(...) +} catch (e) { + if (e instanceof AuthError) { + throw new HttpError(422, 'Validation failed', { message: e.message }) + } else { + throw e + } +} +``` + + + + +```ts title="src/server/actions.ts" +try { + await context.entities.User.update(...) +} catch (e) { + if (e instanceof AuthError) { + throw new HttpError(422, 'Validation failed', { message: e.message }) + } else { + throw e + } +} +``` + + + + +## Customizing the Signup Process + +Sometimes you want to include **extra fields** in your signup process, like first name and last name. + +In Wasp, in this case: + +- you need to define the fields that you want saved in the database, +- you need to customize the `SignupForm`. + +Other times, you might need to just add some **extra UI** elements to the form, like a checkbox for terms of service. In this case, customizing only the UI components is enough. + +Let's see how to do both. + +### 1. Defining Extra Fields + +If we want to **save** some extra fields in our signup process, we need to tell our app they exist. + +We do that by defining an object where the keys represent the field name, and the values are functions that receive the data sent from the client\* and return the value of the field. + + + +\* We exclude the `password` field from this object to prevent it from being saved as plain-text in the database. The `password` field is handled by Wasp's auth backend. + + +First, we add the `auth.signup.additionalFields` field in our `main.wasp` file: + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + address String? +psl=} +``` + +Then we'll define and export the `fields` object from the `server/auth/signup.js` file: + +```ts title="server/auth/signup.js" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + address String? +psl=} +``` + +Then we'll export the `fields` object from the `server/auth/signup.ts` file: + +```ts title="server/auth/signup.ts" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + + + +Read more about the `fields` object in the [API Reference](#signup-fields-customization). + + +Keep in mind, that these field names need to exist on the `userEntity` you defined in your `main.wasp` file e.g. `address` needs to be a field on the `User` entity. + +The field function will receive the data sent from the client and it needs to return the value that will be saved into the database. If the field is invalid, the function should throw an error. + +:::info Using Validation Libraries + +You can use any validation library you want to validate the fields. For example, you can use `zod` like this: + +
    +Click to see the code + + + + +```js title="server/auth/signup.js" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import * as z from 'zod' + +export const fields = defineAdditionalSignupFields({ + address: (data) => { + const AddressSchema = z + .string({ + required_error: 'Address is required', + invalid_type_error: 'Address must be a string', + }) + .min(10, 'Address must be at least 10 characters long') + const result = AddressSchema.safeParse(data.address) + if (result.success === false) { + throw new Error(result.error.issues[0].message) + } + return result.data + }, +}) +``` + + + + +```ts title="server/auth/signup.ts" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import * as z from 'zod' + +export const fields = defineAdditionalSignupFields({ + address: (data) => { + const AddressSchema = z + .string({ + required_error: 'Address is required', + invalid_type_error: 'Address must be a string', + }) + .min(10, 'Address must be at least 10 characters long') + const result = AddressSchema.safeParse(data.address) + if (result.success === false) { + throw new Error(result.error.issues[0].message) + } + return result.data + }, +}) +``` + + + +
    + +::: + +Now that we defined the fields, Wasp knows how to: + +1. Validate the data sent from the client +2. Save the data to the database + +Next, let's see how to customize [Auth UI](../auth/ui) to include those fields. + +### 2. Customizing the Signup Component + +:::tip Using Custom Signup Component + +If you are not using Wasp's Auth UI, you can skip this section. Just make sure to include the extra fields in your custom signup form. + +Read more about using the signup actions for: + +- email auth [here](../auth/email#fields-in-the-email-dict) +- username & password auth [here](../auth/username-and-pass#customizing-the-auth-flow) +::: + +If you are using Wasp's Auth UI, you can customize the `SignupForm` component by passing the `additionalFields` prop to it. It can be either a list of extra fields or a render function. + +#### Using a List of Extra Fields + +When you pass in a list of extra fields to the `SignupForm`, they are added to the form one by one, in the order you pass them in. + +Inside the list, there can be either **objects** or **render functions** (you can combine them): + +1. Objects are a simple way to describe new fields you need, but a bit less flexible than render functions. +2. Render functions can be used to render any UI you want, but they require a bit more code. The render functions receive the `react-hook-form` object and the form state object as arguments. + + + + +```jsx title="client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + + + +Read more about the extra fields in the [API Reference](#signupform-customization). + + +#### Using a Single Render Function + +Instead of passing in a list of extra fields, you can pass in a render function which will receive the `react-hook-form` object and the form state object as arguments. What ever the render function returns, will be rendered below the default fields. + + + + +```jsx title="client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { FormItemGroup } from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + const username = form.watch('username') + return ( + username && ( + + Hello there {username} 👋 + + ) + ) + }} + /> + ) +} +``` + + + + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { FormItemGroup } from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + const username = form.watch('username') + return ( + username && ( + + Hello there {username} 👋 + + ) + ) + }} + /> + ) +} +``` + + + + + + +Read more about the render function in the [API Reference](#signupform-customization). + + +## API Reference + +### Auth Fields + + + + +```wasp title="main.wasp" + title: "My app", + //... + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + usernameAndPassword: {}, // use this or email, not both + email: {}, // use this or usernameAndPassword, not both + google: {}, + gitHub: {}, + }, + onAuthFailedRedirectTo: "/someRoute", + signup: { ... } + } +} + +//... +``` + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + //... + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + usernameAndPassword: {}, // use this or email, not both + email: {}, // use this or usernameAndPassword, not both + google: {}, + gitHub: {}, + }, + onAuthFailedRedirectTo: "/someRoute", + signup: { ... } + } +} + +//... +``` + + + + +`app.auth` is a dictionary with the following fields: + +#### `userEntity: entity` + +The entity representing the user. Its mandatory fields depend on your chosen auth method. + +#### `externalAuthEntity: entity` + +Wasp requires you to set the field `auth.externalAuthEntity` for all authentication methods relying on an external authorizatino provider (e.g., Google). You also need to tweak the Entity referenced by `auth.userEntity`, as shown below. + + + + +```wasp {4,14} title="main.wasp" +//... + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, +//... + +entity User {=psl + id Int @id @default(autoincrement()) + //... + externalAuthAssociations SocialLogin[] +psl=} + +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=} +``` + + + + +```wasp {4,14} title="main.wasp" +//... + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, +//... + +entity User {=psl + id Int @id @default(autoincrement()) + //... + externalAuthAssociations SocialLogin[] +psl=} + +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=} +``` + + + + +:::note +The same `externalAuthEntity` can be used across different social login providers (e.g., both GitHub and Google can use the same entity). +::: + +See [Google docs](../auth/social-auth/google) and [GitHub docs](../auth/social-auth/github) for more details. + +#### `methods: dict` + +A dictionary of auth methods enabled for the app. + + + +#### `onAuthFailedRedirectTo: String` + +The route to which Wasp should redirect unauthenticated user when they try to access a private page (i.e., a page that has `authRequired: true`). +Check out these [essentials docs on auth](../tutorial/auth#adding-auth-to-the-project) to see an example of usage. + +#### `onAuthSucceededRedirectTo: String` + +The route to which Wasp will send a successfully authenticated after a successful login/signup. +The default value is `"/"`. + +:::note +Automatic redirect on successful login only works when using the Wasp-provided [Auth UI](../auth/ui). +::: + +#### `signup: SignupOptions` + +Read more about the signup process customization API in the [Signup Fields Customization](#signup-fields-customization) section. + +### Signup Fields Customization + +If you want to add extra fields to the signup process, the server needs to know how to save them to the database. You do that by defining the `auth.signup.additionalFields` field in your `main.wasp` file. + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} +``` + +Then we'll export the `fields` object from the `server/auth/signup.js` file: + +```ts title="server/auth/signup.js" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} +``` + +Then we'll export the `fields` object from the `server/auth/signup.ts` file: + +```ts title="server/auth/signup.ts" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + +The `fields` object is an object where the keys represent the field name, and the values are functions which receive the data sent from the client\* and return the value of the field. + +If the field value is invalid, the function should throw an error. + + + +\* We exclude the `password` field from this object to prevent it from being saved as plain-text in the database. The `password` field is handled by Wasp's auth backend. + + +### `SignupForm` Customization + +To customize the `SignupForm` component, you need to pass in the `additionalFields` prop. It can be either a list of extra fields or a render function. + + + + +```jsx title="client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + +The extra fields can be either **objects** or **render functions** (you can combine them): + +1. Objects are a simple way to describe new fields you need, but a bit less flexible than render functions. + + The objects have the following properties: + + - `name` + - the name of the field + - `label` + + - the label of the field (used in the UI) + + - `type` + + - the type of the field, which can be `input` or `textarea` + + - `validations` + - an object with the validation rules for the field. The keys are the validation names, and the values are the validation error messages. Read more about the available validation rules in the [react-hook-form docs](https://react-hook-form.com/api/useform/register#register). + +2. Render functions receive the `react-hook-form` object and the form state as arguments, and they can use them to render arbitrary UI elements. + + The render function has the following signature: + + ```ts + ;(form: UseFormReturn, state: FormState) => React.ReactNode + ``` + + - `form` + + - the `react-hook-form` object, read more about it in the [react-hook-form docs](https://react-hook-form.com/api/useform) + - you need to use the `form.register` function to register your fields + + - `state` + + - the form state object which has the following properties: + - `isLoading: boolean` + - whether the form is currently submitting diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/SocialAuthGrid.css b/web/versioned_docs/version-0.11.8/auth/social-auth/SocialAuthGrid.css new file mode 100644 index 0000000000..8efd3b68b0 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/SocialAuthGrid.css @@ -0,0 +1,29 @@ +.social-auth-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-gap: 0.5rem; + margin-bottom: 1rem; +} +.auth-method-box { + display: flex; + flex-direction: column; + justify-content: center; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-pagination-nav-border-radius); + padding: 1.5rem; + transition: all 0.1s ease-in-out; +} +.auth-method-box:hover { + border-color: var(--ifm-pagination-nav-color-hover); +} +.auth-method-box h3 { + margin: 0; + color: var(--ifm-link-color); +} +.auth-method-box p { + margin: 0; + color: var(--ifm-color-secondary-contrast-foreground); +} +.social-auth-info { + color: var(--ifm-color-secondary-contrast-foreground); +} diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/SocialAuthGrid.tsx b/web/versioned_docs/version-0.11.8/auth/social-auth/SocialAuthGrid.tsx new file mode 100644 index 0000000000..a961167377 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/SocialAuthGrid.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import Link from '@docusaurus/Link'; +import "./SocialAuthGrid.css"; + +export function SocialAuthGrid({ + pagePart = "", // e.g. #overrides +}) { + const authMethods = [ + { + title: "Google", + description: "Users sign in with their Google account.", + linkToDocs: "/docs/auth/social-auth/google" + pagePart, + }, + { + title: "Github", + description: "Users sign in with their Github account.", + linkToDocs: "/docs/auth/social-auth/github" + pagePart, + }, + ]; + return ( + <> +
    + {authMethods.map((authMethod) => ( + + ))} +
    +

    + Click on each provider for more details. +

    + + ); +} + +function AuthMethodBox({ + linkToDocs, + title, + description, +}: { + linkToDocs: string; + title: string; + description: string; +}) { + return ( + +

    {title} »

    +

    {description}

    + + ); +} diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/_api-reference-intro.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_api-reference-intro.md new file mode 100644 index 0000000000..ed977b84bd --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/_api-reference-intro.md @@ -0,0 +1,10 @@ +Provider-specific behavior comes down to implementing two functions. + +- `configFn` +- `getUserFieldsFn` + +The reference shows how to define both. + +For behavior common to all providers, check the general [API Reference](../../auth/overview.md#api-reference). + + diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/_default-behaviour.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_default-behaviour.md new file mode 100644 index 0000000000..3ce6c65606 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/_default-behaviour.md @@ -0,0 +1,10 @@ +When a user **signs in for the first time**, Wasp creates a new user account and links it to the chosen auth provider account for future logins. + +Also, if the `userEntity` has: + +- A `username` field: Wasp sets it to a random username (e.g. `nice-blue-horse-14357`). +- A `password` field: Wasp sets it to a random string. + +This is a historical coupling between `auth` methods we plan to remove in the future. + + diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/_getuserfields-type.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_getuserfields-type.md new file mode 100644 index 0000000000..a630a3e562 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/_getuserfields-type.md @@ -0,0 +1,3 @@ +Wasp automatically generates the type `GetUserFieldsFn` to help you correctly type your `getUserFields` function. + + diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/_override-example-intro.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_override-example-intro.md new file mode 100644 index 0000000000..01fad70340 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/_override-example-intro.md @@ -0,0 +1,10 @@ +When a user logs in using a social login provider, the backend receives some data about the user. +Wasp lets you access this data inside the `getUserFieldsFn` function. + +For example, the User entity can include a `displayName` field which you can set based on the details received from the provider. + +Wasp also lets you customize the configuration of the providers' settings using the `configFn` function. + +Let's use this example to show both functions in action: + + diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/_override-intro.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_override-intro.md new file mode 100644 index 0000000000..0c399d3ca0 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/_override-intro.md @@ -0,0 +1,10 @@ +Wasp lets you override the default behavior. You can create custom setups, such as allowing users to define a custom username rather instead of getting a randomly generated one. + +There are two mechanisms (functions) used for overriding the default behavior: + +- `getUserFieldsFn` +- `configFn` + +Let's explore them in more detail. + + diff --git a/web/docs/auth/social-auth/_username-generate-explanation.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_username-generate-explanation.md similarity index 100% rename from web/docs/auth/social-auth/_username-generate-explanation.md rename to web/versioned_docs/version-0.11.8/auth/social-auth/_username-generate-explanation.md diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/_using-auth-note.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_using-auth-note.md new file mode 100644 index 0000000000..7fe740be18 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/_using-auth-note.md @@ -0,0 +1,3 @@ +To read more about how to set up the logout button and get access to the logged-in user in both client and server code, read the docs on [using auth](../../auth/overview). + + diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/_wasp-file-structure-note.md b/web/versioned_docs/version-0.11.8/auth/social-auth/_wasp-file-structure-note.md new file mode 100644 index 0000000000..ea441c1309 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/_wasp-file-structure-note.md @@ -0,0 +1,16 @@ +Here's a skeleton of how our `main.wasp` should look like after we're done: + +```wasp title="main.wasp" +// Configuring the social authentication +app myApp { + auth: { ... } +} + +// Defining entities +entity User { ... } +entity SocialLogin { ... } + +// Defining routes and pages +route LoginRoute { ... } +page LoginPage { ... } +``` diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/github.md b/web/versioned_docs/version-0.11.8/auth/social-auth/github.md new file mode 100644 index 0000000000..5af5c70180 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/github.md @@ -0,0 +1,606 @@ +--- +title: GitHub +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; +import DefaultBehaviour from './\_default-behaviour.md'; +import OverrideIntro from './\_override-intro.md'; +import OverrideExampleIntro from './\_override-example-intro.md'; +import UsingAuthNote from './\_using-auth-note.md'; +import WaspFileStructureNote from './\_wasp-file-structure-note.md'; +import UsernameGenerateExplanation from './\_username-generate-explanation.md'; +import GetUserFieldsType from './\_getuserfields-type.md'; +import ApiReferenceIntro from './\_api-reference-intro.md'; + +Wasp supports Github Authentication out of the box. +GitHub is a great external auth choice when you're building apps for developers, as most of them already have a GitHub account. + +Letting your users log in using their GitHub accounts turns the signup process into a breeze. + +Let's walk through enabling Github Authentication, explain some of the default settings, and show how to override them. + +## Setting up Github Auth + +Enabling GitHub Authentication comes down to a series of steps: + +1. Enabling GitHub authentication in the Wasp file. +1. Adding the necessary Entities. +1. Creating a GitHub OAuth app. +1. Adding the neccessary Routes and Pages +1. Using Auth UI components in our Pages. + + + +### 1. Adding Github Auth to Your Wasp File + +Let's start by properly configuring the Auth object: + + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // highlight-next-line + // 1. Specify the User entity (we'll define it next) + // highlight-next-line + userEntity: User, + // highlight-next-line + // 2. Specify the SocialLogin entity (we'll define it next) + // highlight-next-line + externalAuthEntity: SocialLogin, + methods: { + // highlight-next-line + // 3. Enable Github Auth + // highlight-next-line + gitHub: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // highlight-next-line + // 1. Specify the User entity (we'll define it next) + // highlight-next-line + userEntity: User, + // highlight-next-line + // 2. Specify the SocialLogin entity (we'll define it next) + // highlight-next-line + externalAuthEntity: SocialLogin, + methods: { + // highlight-next-line + // 3. Enable Github Auth + // highlight-next-line + gitHub: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +### 2. Add the Entities + +Let's now define the entities acting as `app.auth.userEntity` and `app.auth.externalAuthEntity`: + + + + +```wasp title="main.wasp" +// ... +// highlight-next-line +// 4. Define the User entity +// highlight-next-line +entity User {=psl + id Int @id @default(autoincrement()) + // ... + externalAuthAssociations SocialLogin[] +psl=} + +// highlight-next-line +// 5. Define the SocialLogin entity +// highlight-next-line +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=} +``` + + + + +```wasp title="main.wasp" +// ... +// highlight-next-line +// 4. Define the User entity +// highlight-next-line +entity User {=psl + id Int @id @default(autoincrement()) + // ... + externalAuthAssociations SocialLogin[] +psl=} + +// highlight-next-line +// 5. Define the SocialLogin entity +// highlight-next-line +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=} +``` + + + + +`externalAuthEntity` and `userEntity` are explained in [the social auth overview](../../auth/social-auth/overview#social-login-entity). + +### 3. Creating a GitHub OAuth App + +To use GitHub as an authentication method, you'll first need to create a GitHub OAuth App and provide Wasp with your client key and secret. Here's how you do it: + +1. Log into your GitHub account and navigate to: https://github.com/settings/developers. +2. Select **New OAuth App**. +3. Supply required information. + +GitHub Applications Screenshot + +- For **Authorization callback URL**: + - For development, put: `http://localhost:3000/auth/login/github`. + - Once you know on which URL your app will be deployed, you can create a new app with that URL instead e.g. `https://someotherhost.com/auth/login/github`. + +4. Hit **Register application**. +5. Hit **Generate a new client secret** on the next page. +6. Copy your Client ID and Client secret as you'll need them in the next step. + +### 4. Adding Environment Variables + +Add these environment variables to the `.env.server` file at the root of your project (take their values from the previous step): + +```bash title=".env.server" +GITHUB_CLIENT_ID=your-github-client-id +GITHUB_CLIENT_SECRET=your-github-client-secret +``` + +### 5. Adding the Necessary Routes and Pages + +Let's define the necessary authentication Routes and Pages. + +Add the following code to your `main.wasp` file: + + + + +```wasp title="main.wasp" +// ... + +// 6. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.jsx" +} +``` + + + + +```wasp title="main.wasp" +// ... + +// 6. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.tsx" +} +``` + + + + +We'll define the React components for these pages in the `client/pages/auth.{jsx,tsx}` file below. + +### 6. Creating the Client Pages + +:::info +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../../project/css-frameworks). +::: + +Let's create a `auth.{jsx,tsx}` file in the `client/pages` folder and add the following to it: + + + + +```tsx title="client/pages/auth.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +export function Login() { + return ( + + + + ) +} + +// A layout component to center the content +export function Layout({ children }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ) +} +``` + +
    + + +```tsx title="client/pages/auth.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +export function Login() { + return ( + + + + ) +} + +// A layout component to center the content +export function Layout({ children }: { children: React.ReactNode }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ) +} +``` + +
    +
    + +We imported the generated Auth UI component and used them in our pages. Read more about the Auth UI components [here](../../auth/ui). + +### Conclusion + +Yay, we've successfully set up Github Auth! 🎉 + +![Github Auth](/img/auth/github.png) + +Running `wasp db migrate-dev` and `wasp start` should now give you a working app with authentication. +To see how to protect specific pages (i.e., hide them from non-authenticated users), read the docs on [using auth](../../auth/overview). + +## Default Behaviour + +Add `gitHub: {}` to the `auth.methods` dictionary to use it with default settings. + + + + +```wasp title=main.wasp {10} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + gitHub: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +```wasp title=main.wasp {10} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + gitHub: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + + + +## Overrides + + + +### Using the User's Provider Account Details + + + + + + +```wasp title="main.wasp" {11-12,22} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + gitHub: { + configFn: import { getConfig } from "@server/auth/github.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + displayName String + externalAuthAssociations SocialLogin[] +psl=} + +// ... +``` + +```js title=src/server/auth/github.js +import { generateAvailableDictionaryUsername } from "@wasp/core/auth.js"; + +export const getUserFields = async (_context, args) => { + const username = await generateAvailableDictionaryUsername(); + const displayName = args.profile.displayName; + return { username, displayName }; +}; + +export function getConfig() { + return { + clientID // look up from env or elsewhere + clientSecret // look up from env or elsewhere + scope: [], + }; +} +``` + + + + +```wasp title="main.wasp" {11-12,22} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + gitHub: { + configFn: import { getConfig } from "@server/auth/github.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + displayName String + externalAuthAssociations SocialLogin[] +psl=} + +// ... +``` + +```ts title=src/server/auth/github.ts +import type { GetUserFieldsFn } from '@wasp/types' +import { generateAvailableDictionaryUsername } from '@wasp/core/auth.js' + +export const getUserFields: GetUserFieldsFn = async (_context, args) => { + const username = await generateAvailableDictionaryUsername() + const displayName = args.profile.displayName + return { username, displayName } +} + +export function getConfig() { + return { + clientID, // look up from env or elsewhere + clientSecret, // look up from env or elsewhere + scope: [], + } +} +``` + + + + + + +## Using Auth + + + +## API Reference + + + + + + +```wasp title="main.wasp" {11-12} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + gitHub: { + configFn: import { getConfig } from "@server/auth/github.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +```wasp title="main.wasp" {11-12} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + gitHub: { + configFn: import { getConfig } from "@server/auth/github.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/github.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +The `gitHub` dict has the following properties: + +- #### `configFn: ServerImport` + + This function should return an object with the Client ID, Client Secret, and scope for the OAuth provider. + + + + + ```js title=src/server/auth/github.js + export function getConfig() { + return { + clientID, // look up from env or elsewhere + clientSecret, // look up from env or elsewhere + scope: [], + } + } + ``` + + + + + ```ts title=src/server/auth/github.ts + export function getConfig() { + return { + clientID, // look up from env or elsewhere + clientSecret, // look up from env or elsewhere + scope: [], + } + } + ``` + + + + +- #### `getUserFieldsFn: ServerImport` + + This function should return the user fields to use when creating a new user. + + The `context` contains the `User` entity, and the `args` object contains GitHub profile information. + You can do whatever you want with this information (e.g., generate a username). + + Here is how you could generate a username based on the Github display name: + + + + ```js title=src/server/auth/github.js + import { generateAvailableUsername } from '@wasp/core/auth.js' + + export const getUserFields = async (_context, args) => { + const username = await generateAvailableUsername( + args.profile.displayName.split(' '), + { separator: '.' } + ) + return { username } + } + ``` + + + + + ```ts title=src/server/auth/github.ts + import type { GetUserFieldsFn } from '@wasp/types' + import { generateAvailableUsername } from '@wasp/core/auth.js' + + export const getUserFields: GetUserFieldsFn = async (_context, args) => { + const username = await generateAvailableUsername( + args.profile.displayName.split(' '), + { separator: '.' } + ) + return { username } + } + ``` + + + + + diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/google.md b/web/versioned_docs/version-0.11.8/auth/social-auth/google.md new file mode 100644 index 0000000000..d49750bde3 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/google.md @@ -0,0 +1,651 @@ +--- +title: Google +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; +import DefaultBehaviour from './\_default-behaviour.md'; +import OverrideIntro from './\_override-intro.md'; +import OverrideExampleIntro from './\_override-example-intro.md'; +import UsingAuthNote from './\_using-auth-note.md'; +import WaspFileStructureNote from './\_wasp-file-structure-note.md'; +import UsernameGenerateExplanation from './\_username-generate-explanation.md'; +import GetUserFieldsType from './\_getuserfields-type.md'; +import ApiReferenceIntro from './\_api-reference-intro.md'; + +Wasp supports Google Authentication out of the box. +Google Auth is arguably the best external auth option, as most users on the web already have Google accounts. + +Enabling it lets your users log in using their existing Google accounts, greatly simplifying the process and enhancing the user experience. + +Let's walk through enabling Google authentication, explain some of the default settings, and show how to override them. + +## Setting up Google Auth + +Enabling Google Authentication comes down to a series of steps: + +1. Enabling Google authentication in the Wasp file. +1. Adding the necessary Entities. +1. Creating a Google OAuth app. +1. Adding the neccessary Routes and Pages +1. Using Auth UI components in our Pages. + + + +### 1. Adding Google Auth to Your Wasp File + +Let's start by properly configuring the Auth object: + + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // highlight-next-line + // 1. Specify the User entity (we'll define it next) + // highlight-next-line + userEntity: User, + // highlight-next-line + // 2. Specify the SocialLogin entity (we'll define it next) + // highlight-next-line + externalAuthEntity: SocialLogin, + methods: { + // highlight-next-line + // 3. Enable Google Auth + // highlight-next-line + google: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // highlight-next-line + // 1. Specify the User entity (we'll define it next) + // highlight-next-line + userEntity: User, + // highlight-next-line + // 2. Specify the SocialLogin entity (we'll define it next) + // highlight-next-line + externalAuthEntity: SocialLogin, + methods: { + // highlight-next-line + // 3. Enable Google Auth + // highlight-next-line + google: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +`externalAuthEntity` and `userEntity` are explained in [the social auth overview](../../auth/social-auth/overview#social-login-entity). + +### 2. Adding the Entities + +Let's now define the entities acting as `app.auth.userEntity` and `app.auth.externalAuthEntity`: + + + + +```wasp title="main.wasp" +// ... +// highlight-next-line +// 4. Define the User entity +// highlight-next-line +entity User {=psl + id Int @id @default(autoincrement()) + // ... + externalAuthAssociations SocialLogin[] +psl=} + +// highlight-next-line +// 5. Define the SocialLogin entity +// highlight-next-line +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=} +``` + + + + +```wasp title="main.wasp" +// ... +// highlight-next-line +// 4. Define the User entity +// highlight-next-line +entity User {=psl + id Int @id @default(autoincrement()) + // ... + externalAuthAssociations SocialLogin[] +psl=} + +// highlight-next-line +// 5. Define the SocialLogin entity +// highlight-next-line +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=} +``` + + + + +### 3. Creating a Google OAuth App + +To use Google as an authentication method, you'll first need to create a Google project and provide Wasp with your client key and secret. Here's how you do it: + +1. Create a Google Cloud Platform account if you do not already have one: https://cloud.google.com/ +2. Create and configure a new Google project here: https://console.cloud.google.com/home/dashboard + +![Google Console Screenshot 1](/img/integrations-google-1.jpg) + +![Google Console Screenshot 2](/img/integrations-google-2.jpg) + +3. Search for **OAuth** in the top bar, click on **OAuth consent screen**. + +![Google Console Screenshot 3](/img/integrations-google-3.jpg) + +- Select what type of app you want, we will go with **External**. + + ![Google Console Screenshot 4](/img/integrations-google-4.jpg) + +- Fill out applicable information on Page 1. + + ![Google Console Screenshot 5](/img/integrations-google-5.jpg) + +- On Page 2, Scopes, you should select `userinfo.profile`. You can optionally search for other things, like `email`. + + ![Google Console Screenshot 6](/img/integrations-google-6.jpg) + + ![Google Console Screenshot 7](/img/integrations-google-7.jpg) + + ![Google Console Screenshot 8](/img/integrations-google-8.jpg) + +- Add any test users you want on Page 3. + + ![Google Console Screenshot 9](/img/integrations-google-9.jpg) + +4. Next, click **Credentials**. + +![Google Console Screenshot 10](/img/integrations-google-10.jpg) + +- Select **Create Credentials**. +- Select **OAuth client ID**. + + ![Google Console Screenshot 11](/img/integrations-google-11.jpg) + +- Complete the form + + ![Google Console Screenshot 12](/img/integrations-google-12.jpg) + +- Under Authorized redirect URIs, put in: `http://localhost:3000/auth/login/google` + + ![Google Console Screenshot 13](/img/integrations-google-13.jpg) + + - Once you know on which URL(s) your API server will be deployed, also add those URL(s). + - For example: `https://someotherhost.com/auth/login/google` + +- When you save, you can click the Edit icon and your credentials will be shown. + + ![Google Console Screenshot 14](/img/integrations-google-14.jpg) + +5. Copy your Client ID and Client secret as you will need them in the next step. + +### 4. Adding Environment Variables + +Add these environment variables to the `.env.server` file at the root of your project (take their values from the previous step): + +```bash title=".env.server" +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +``` + +### 5. Adding the Necessary Routes and Pages + +Let's define the necessary authentication Routes and Pages. + +Add the following code to your `main.wasp` file: + + + + +```wasp title="main.wasp" +// ... + +// 6. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.jsx" +} +``` + + + + +```wasp title="main.wasp" +// ... + +// 6. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.tsx" +} +``` + + + + +We'll define the React components for these pages in the `client/pages/auth.{jsx,tsx}` file below. + +### 6. Create the Client Pages + +:::info +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../../project/css-frameworks). +::: + +Let's now create a `auth.{jsx,tsx}` file in the `client/pages`. +It should have the following code: + + + + +```tsx title="client/pages/auth.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +export function Login() { + return ( + + + + ) +} + +// A layout component to center the content +export function Layout({ children }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ) +} +``` + +
    + + +```tsx title="client/pages/auth.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +export function Login() { + return ( + + + + ) +} + +// A layout component to center the content +export function Layout({ children }: { children: React.ReactNode }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ) +} +``` + +
    +
    + +:::info Auth UI +Our pages use an automatically-generated Auth UI component. Read more about Auth UI components [here](../../auth/ui). +::: + +### Conclusion + +Yay, we've successfully set up Google Auth! 🎉 + +![Google Auth](/img/auth/google.png) + +Running `wasp db migrate-dev` and `wasp start` should now give you a working app with authentication. +To see how to protect specific pages (i.e., hide them from non-authenticated users), read the docs on [using auth](../../auth/overview). + +## Default Behaviour + +Add `google: {}` to the `auth.methods` dictionary to use it with default settings: + + + + +```wasp title=main.wasp {10} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +```wasp title=main.wasp {10} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + + + +## Overrides + + + +### Using the User's Provider Account Details + + + + + + +```wasp title="main.wasp" {11-12,22} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: { + configFn: import { getConfig } from "@server/auth/google.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + displayName String + externalAuthAssociations SocialLogin[] +psl=} + +// ... +``` + +```js title=src/server/auth/google.js +import { generateAvailableDictionaryUsername } from '@wasp/core/auth.js' + +export const getUserFields = async (_context, args) => { + const username = await generateAvailableDictionaryUsername() + const displayName = args.profile.displayName + return { username, displayName } +} + +export function getConfig() { + return { + clientID, // look up from env or elsewhere + clientSecret, // look up from env or elsewhere + scope: ['profile', 'email'], + } +} +``` + + + + +```wasp title="main.wasp" {11-12,22} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: { + configFn: import { getConfig } from "@server/auth/google.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + displayName String + externalAuthAssociations SocialLogin[] +psl=} + +// ... +``` + +```ts title=src/server/auth/google.ts +import type { GetUserFieldsFn } from '@wasp/types' +import { generateAvailableDictionaryUsername } from '@wasp/core/auth.js' + +export const getUserFields: GetUserFieldsFn = async (_context, args) => { + const username = await generateAvailableDictionaryUsername() + const displayName = args.profile.displayName + return { username, displayName } +} + +export function getConfig() { + return { + clientID, // look up from env or elsewhere + clientSecret, // look up from env or elsewhere + scope: ['profile', 'email'], + } +} +``` + + + + + + +## Using Auth + + + +## API Reference + + + + + + +```wasp title="main.wasp" {11-12} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: { + configFn: import { getConfig } from "@server/auth/google.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +```wasp title="main.wasp" {11-12} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: { + configFn: import { getConfig } from "@server/auth/google.js", + getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} +``` + + + + +The `google` dict has the following properties: + +- #### `configFn: ServerImport` + + This function must return an object with the Client ID, the Client Secret, and the scope for the OAuth provider. + + + + + ```js title=src/server/auth/google.js + export function getConfig() { + return { + clientID, // look up from env or elsewhere + clientSecret, // look up from env or elsewhere + scope: ['profile', 'email'], + } + } + ``` + + + + + ```ts title=src/server/auth/google.ts + export function getConfig() { + return { + clientID, // look up from env or elsewhere + clientSecret, // look up from env or elsewhere + scope: ['profile', 'email'], + } + } + ``` + + + + +- #### `getUserFieldsFn: ServerImport` + + This function must return the user fields to use when creating a new user. + + The `context` contains the `User` entity, and the `args` object contains Google profile information. + You can do whatever you want with this information (e.g., generate a username). + + Here is how to generate a username based on the Google display name: + + + + ```js title=src/server/auth/google.js + import { generateAvailableUsername } from '@wasp/core/auth.js' + + export const getUserFields = async (_context, args) => { + const username = await generateAvailableUsername( + args.profile.displayName.split(' '), + { separator: '.' } + ) + return { username } + } + ``` + + + + + ```ts title=src/server/auth/google.ts + import type { GetUserFieldsFn } from '@wasp/types' + import { generateAvailableUsername } from '@wasp/core/auth.js' + + export const getUserFields: GetUserFieldsFn = async (_context, args) => { + const username = await generateAvailableUsername( + args.profile.displayName.split(' '), + { separator: '.' } + ) + return { username } + } + ``` + + + + + + + diff --git a/web/versioned_docs/version-0.11.8/auth/social-auth/overview.md b/web/versioned_docs/version-0.11.8/auth/social-auth/overview.md new file mode 100644 index 0000000000..9faaa35434 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/social-auth/overview.md @@ -0,0 +1,497 @@ +--- +title: Overview +--- + +import { SocialAuthGrid } from './SocialAuthGrid'; +import DefaultBehaviour from './\_default-behaviour.md'; +import OverrideIntro from './\_override-intro.md'; +import GetUserFieldsType from './\_getuserfields-type.md'; + +Social login options (e.g., _Log in with Google_) are a great (maybe even the best) solution for handling user accounts. +A famous old developer joke tells us _"The best auth system is the one you never have to make."_ + +Wasp wants to make adding social login options to your app as painless as possible. + +Using different social providers gives users a chance to sign into your app via their existing accounts on other platforms (Google, GitHub, etc.). + +This page goes through the common behaviors between all supported social login providers and shows you how to customize them. +It also gives an overview of Wasp's UI helpers - the quickest possible way to get started with social auth. + +## Available Providers + +Wasp currently supports the following social login providers: + + + +## Social Login Entity + +Wasp requires you to declare a `userEntity` for all `auth` methods (social or otherwise). +This field tells Wasp which Entity represents the user. + +Additionally, when using `auth` methods that rely on external providers(e.g., _Google_), you must also declare an `externalAuthEntity`. +This tells Wasp which Entity represents the user's link with the social provider. + +Both fields fall under `app.auth`. Here's what the full setup looks like: + + + + +```wasp title=main.wasp +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // highlight-next-line + userEntity: User, + // highlight-next-line + externalAuthEntity: SocialLogin, + methods: { + google: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} + +// highlight-next-line +entity User {=psl + id Int @id @default(autoincrement()) + //... + externalAuthAssociations SocialLogin[] +psl=} + +// highlight-next-line +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=} +``` + + + + +```wasp title=main.wasp +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // highlight-next-line + userEntity: User, + // highlight-next-line + externalAuthEntity: SocialLogin, + methods: { + google: {} + }, + onAuthFailedRedirectTo: "/login" + }, +} + +// highlight-next-line +entity User {=psl + id Int @id @default(autoincrement()) + //... + externalAuthAssociations SocialLogin[] +psl=} + +// highlight-next-line +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=} +``` + + + + + + +To learn more about what the fields on these entities represent, look at the [API Reference](#api-reference). + + + +:::note +Wasp uses the same `externalAuthEntity` for all social login providers (e.g. both GitHub and Google use the same entity). +::: + +## Default Behavior + + + +## Overrides + +Wasp lets you override the default behavior. You can create custom setups, such as allowing users to define a custom username rather instead of getting a randomly generated one. + +### Allowing User to Set Their Username + +If you want to modify the signup flow (e.g., let users choose their own usernames), you will need to go through three steps: + +1. The first step is adding a `isSignupComplete` property to your `User` Entity. This field will signals whether the user has completed the signup process. +2. The second step is overriding the default signup behavior. +3. The third step is implementing the rest of your signup flow and redirecting users where appropriate. + +Let's go through both steps in more detail. + +#### 1. Adding the `isSignupComplete` Field to the `User` Entity + + + + +```wasp title=main.wasp +entity User {=psl + id Int @id @default(autoincrement()) + username String? @unique + // highlight-next-line + isSignupComplete Boolean @default(false) + externalAuthAssociations SocialLogin[] +psl=} +``` + + + + +```wasp title=main.wasp +entity User {=psl + id Int @id @default(autoincrement()) + username String? @unique + // highlight-next-line + isSignupComplete Boolean @default(false) + externalAuthAssociations SocialLogin[] +psl=} +``` + + + + +#### 2. Overriding the Default Behavior + +Declare an import under `app.auth.methods.google.getUserFieldsFn` (the example assumes you're using Google): + + + + +```wasp title=main.wasp +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: { + // highlight-next-line + getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} + +// ... +``` + +And implement the imported function. + +```js title=src/server/auth/google.js +export const getUserFields = async (_context, _args) => { + return { + isSignupComplete: false, + } +} +``` + + + + +```wasp title=main.wasp +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: { + // highlight-next-line + getUserFieldsFn: import { getUserFields } from "@server/auth/google.js" + } + }, + onAuthFailedRedirectTo: "/login" + }, +} + +// ... +``` + +And implement the imported function: + +```ts title=src/server/auth/google.ts +import { GetUserFieldsFn } from '@wasp/types' + +export const getUserFields: GetUserFieldsFn = async (_context, _args) => { + return { + isSignupComplete: false, + } +} +``` + + + + + + +#### 3. Showing the Correct State on the Client + +You can query the user's `isSignupComplete` flag on the client with the [`useAuth()`](../../auth/overview) hook. +Depending on the flag's value, you can redirect users to the appropriate signup step. + +For example: + +1. When the user lands on the homepage, check the value of `user.isSignupComplete`. +2. If it's `false`, it means the user has started the signup process but hasn't yet chosen their username. Therefore, you can redirect them to `EditUserDetailsPage` where they can edit the `username` property. + + + + +```jsx title=client/HomePage.jsx +import useAuth from '@wasp/auth/useAuth' +import { Redirect } from 'react-router-dom' + +export function HomePage() { + const { data: user } = useAuth() + + if (user.isSignupComplete === false) { + return + } + + // ... +} +``` + + + + +```tsx title=client/HomePage.tsx +import useAuth from '@wasp/auth/useAuth' +import { Redirect } from 'react-router-dom' + +export function HomePage() { + const { data: user } = useAuth() + + if (user.isSignupComplete === false) { + return + } + + // ... +} +``` + +The same general principle applies to more complex signup procedures, just change the boolean `isSignupComplete` property to a property like `currentSignupStep` that can hold more values. + + + + +### Using the User's Provider Account Details + +Account details are provider-specific. +Each provider has their own rules for defining the `getUserFieldsFn` and `configFn` functions: + + + +## UI Helpers + +:::tip Use Auth UI +[Auth UI](../../auth/ui) is a common name for all high-level auth forms that come with Wasp. + +These include fully functional auto-generated login and signup forms with working social login buttons. +If you're looking for the fastest way to get your auth up and running, that's where you should look. + +The UI helpers described below are lower-level and are useful for creating your custom forms. +::: + +Wasp provides sign-in buttons and URLs for each of the supported social login providers. + + + + +```jsx title=client/LoginPage.jsx +import { + SignInButton as GoogleSignInButton, + signInUrl as googleSignInUrl, +} from '@wasp/auth/helpers/Google' +import { + SignInButton as GitHubSignInButton, + signInUrl as gitHubSignInUrl, +} from '@wasp/auth/helpers/GitHub' + +export const LoginPage = () => { + return ( + <> + + + {/* or */} + Sign in with Google + Sign in with GitHub + + ) +} +``` + + + + +```tsx title=client/LoginPage.tsx +import { + SignInButton as GoogleSignInButton, + signInUrl as googleSignInUrl, +} from '@wasp/auth/helpers/Google' +import { + SignInButton as GitHubSignInButton, + signInUrl as gitHubSignInUrl, +} from '@wasp/auth/helpers/GitHub' + +export const LoginPage = () => { + return ( + <> + + + {/* or */} + Sign in with Google + Sign in with GitHub + + ) +} +``` + + + + +If you need even more customization, you can create your custom components using `signInUrl`s. + +## API Reference + +### Fields in the `app.auth` Dictionary and Overrides + +For more information on: + +- Allowed fields in `app.auth` +- `getUserFields` and `configFn` functions + +Check the provider-specific API References: + + + +### The `externalAuthEntity` and Its Fields + +Using social login providers requires you to define _an External Auth Entity_ and declare it with the `app.auth.externalAuthEntity` field. +This Entity holds the data relevant to the social provider. +All social providers share the same Entity. + + + + +```wasp title=main.wasp {4-10} +// ... + +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=} + +// ... +``` + + + + +```wasp title=main.wasp {4-10} +// ... + +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=} + +// ... +``` + + + + +:::info +You don't need to know these details, you can just copy and paste the entity definition above and you are good to go. +::: + +The Entity acting as `app.auth.externalAuthEntity` must include the following fields: + +- `provider` - The provider's name (e.g. `google`, `github`, etc.). +- `providerId` - The user's ID on the provider's platform. +- `userId` - The user's ID on your platform (this references the `id` field from the Entity acting as `app.auth.userEntity`). +- `user` - A relation to the `userEntity` (see [the `userEntity` section](#expected-fields-on-the-userentity)) for more details. +- `createdAt` - A timestamp of when the association was created. +- `@@unique([provider, providerId, userId])` - A unique constraint on the combination of `provider`, `providerId` and `userId`. + +### Expected Fields on the `userEntity` + +Using Social login providers requires you to add one extra field to the Entity acting as `app.auth.userEntity`: + +- `externalAuthAssociations` - A relation to the `externalAuthEntity` (see [the `externalAuthEntity` section](#the-externalauthentity-and-its-fields) for more details). + + + + +```wasp title=main.wasp {6} +// ... + +entity User {=psl + id Int @id @default(autoincrement()) + //... + externalAuthAssociations SocialLogin[] +psl=} + +// ... +``` + + + + +```wasp title=main.wasp {6} +// ... + +entity User {=psl + id Int @id @default(autoincrement()) + //... + externalAuthAssociations SocialLogin[] +psl=} + +// ... +``` + + + diff --git a/web/versioned_docs/version-0.11.8/auth/ui.md b/web/versioned_docs/version-0.11.8/auth/ui.md new file mode 100644 index 0000000000..d1fae262d8 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/ui.md @@ -0,0 +1,616 @@ +--- +title: Auth UI +--- + +import { EmailPill, UsernameAndPasswordPill, GithubPill, GooglePill } from "./Pills"; + +To make using authentication in your app as easy as possible, Wasp generates the server-side code but also the client-side UI for you. It enables you to quickly get the login, signup, password reset and email verification flows in your app. + +Below we cover all of the available UI components and how to use them. + +![Auth UI](/img/authui/all_screens.gif) + +## Overview + +After Wasp generates the UI components for your auth, you can use it as is, or customize it to your liking. + +Based on the authentication providers you enabled in your `main.wasp` file, the Auth UI will show the corresponding UI (form and buttons). For example, if you enabled e-mail authentication: + + + + +```wasp {5} title="main.wasp" +app MyApp { + //... + auth: { + methods: { + email: {}, + }, + // ... + } +} +``` + + + + +```wasp {5} title="main.wasp" +app MyApp { + //... + auth: { + methods: { + email: {}, + }, + // ... + } +} +``` + + + + +You'll get the following UI: + +![Auth UI](/img/authui/login.png) + +And then if you enable Google and Github: + + + + +```wasp title="main.wasp" {6-7} +app MyApp { + //... + auth: { + methods: { + email: {}, + google: {}, + github: {}, + }, + // ... + } +} +``` + + + + +```wasp title="main.wasp" {6-7} +app MyApp { + //... + auth: { + methods: { + email: {}, + google: {}, + github: {}, + }, + // ... + } +} +``` + + + + +The form will automatically update to look like this: + +![Auth UI](/img/authui/multiple_providers.png) + +Let's go through all of the available components and how to use them. + +## Auth Components + +The following components are available for you to use in your app: + +- [Login form](#login-form) +- [Signup form](#signup-form) +- [Forgot password form](#forgot-password-form) +- [Reset password form](#reset-password-form) +- [Verify email form](#verify-email-form) + +### Login Form + +Used with , , and authentication. + +![Login form](/img/authui/login.png) + +You can use the `LoginForm` component to build your login page: + + + + +```wasp title="main.wasp" +// ... + +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { LoginPage } from "@client/LoginPage.jsx" +} +``` + +```tsx title="client/LoginPage.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +// Use it like this +export function LoginPage() { + return +} +``` + + + + +```wasp title="main.wasp" +// ... + +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { LoginPage } from "@client/LoginPage.tsx" +} +``` + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +// Use it like this +export function LoginPage() { + return +} +``` + + + + +It will automatically show the correct authentication providers based on your `main.wasp` file. + +### Signup Form + +Used with , , and authentication. + +![Signup form](/img/authui/signup.png) + +You can use the `SignupForm` component to build your signup page: + + + + +```wasp title="main.wasp" +// ... + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { SignupPage } from "@client/SignupPage.jsx" +} +``` + +```tsx title="client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' + +// Use it like this +export function SignupPage() { + return +} +``` + + + + +```wasp title="main.wasp" +// ... + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { SignupPage } from "@client/SignupPage.tsx" +} +``` + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' + +// Use it like this +export function SignupPage() { + return +} +``` + + + + +It will automatically show the correct authentication providers based on your `main.wasp` file. + +Read more about customizing the signup process like adding additional fields or extra UI in the [Using Auth](../auth/overview#customizing-the-signup-process) section. + +### Forgot Password Form + +Used with authentication. + +If users forget their password, they can use this form to reset it. + +![Forgot password form](/img/authui/forgot_password.png) + +You can use the `ForgotPasswordForm` component to build your own forgot password page: + + + + +```wasp title="main.wasp" +// ... + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } +page RequestPasswordResetPage { + component: import { ForgotPasswordPage } from "@client/ForgotPasswordPage.jsx" +} +``` + +```tsx title="client/ForgotPasswordPage.jsx" +import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword' + +// Use it like this +export function ForgotPasswordPage() { + return +} +``` + + + + +```wasp title="main.wasp" +// ... + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage } +page RequestPasswordResetPage { + component: import { ForgotPasswordPage } from "@client/ForgotPasswordPage.tsx" +} +``` + +```tsx title="client/ForgotPasswordPage.tsx" +import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword' + +// Use it like this +export function ForgotPasswordPage() { + return +} +``` + + + + +### Reset Password Form + +Used with authentication. + +After users click on the link in the email they receive after submitting the forgot password form, they will be redirected to this form where they can reset their password. + +![Reset password form](/img/authui/reset_password.png) + +You can use the `ResetPasswordForm` component to build your reset password page: + + + + +```wasp title="main.wasp" +// ... + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { ResetPasswordPage } from "@client/ResetPasswordPage.jsx" +} +``` + +```tsx title="client/ResetPasswordPage.jsx" +import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword' + +// Use it like this +export function ResetPasswordPage() { + return +} +``` + + + + +```wasp title="main.wasp" +// ... + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { ResetPasswordPage } from "@client/ResetPasswordPage.tsx" +} +``` + +```tsx title="client/ResetPasswordPage.tsx" +import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword' + +// Use it like this +export function ResetPasswordPage() { + return +} +``` + + + + +### Verify Email Form + +Used with authentication. + +After users sign up, they will receive an email with a link to this form where they can verify their email. + +![Verify email form](/img/authui/email_verification.png) + +You can use the `VerifyEmailForm` component to build your email verification page: + + + + +```wasp title="main.wasp" +// ... + +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { VerifyEmailPage } from "@client/VerifyEmailPage.jsx" +} +``` + +```tsx title="client/VerifyEmailPage.jsx" +import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail' + +// Use it like this +export function VerifyEmailPage() { + return +} +``` + + + + +```wasp title="main.wasp" +// ... + +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { VerifyEmailPage } from "@client/VerifyEmailPage.tsx" +} +``` + +```tsx title="client/VerifyEmailPage.tsx" +import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail' + +// Use it like this +export function VerifyEmailPage() { + return +} +``` + + + + +## Customization 💅🏻 + +You customize all of the available forms by passing props to them. + +Props you can pass to all of the forms: + +1. `appearance` - customize the form colors (via design tokens) +2. `logo` - path to your logo +3. `socialLayout` - layout of the social buttons, which can be `vertical` or `horizontal` + +### 1. Customizing the Colors + +We use [Stitches](https://stitches.dev/) to style the Auth UI. You can customize the styles by overriding the default theme tokens. + +:::info List of all available tokens + +See the [list of all available tokens](https://github.com/wasp-lang/wasp/blob/release/waspc/data/Generator/templates/react-app/src/stitches.config.js) which you can override. + +::: + + + + +```js title="client/appearance.js" +export const authAppearance = { + colors: { + brand: '#5969b8', // blue + brandAccent: '#de5998', // pink + submitButtonText: 'white', + }, +} +``` + +```jsx title="client/LoginPage.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' +import { authAppearance } from './appearance' + +export function LoginPage() { + return ( + + ) +} +``` + + + + +```ts title="client/appearance.ts" +import type { CustomizationOptions } from '@wasp/auth/forms/types' + +export const authAppearance: CustomizationOptions['appearance'] = { + colors: { + brand: '#5969b8', // blue + brandAccent: '#de5998', // pink + submitButtonText: 'white', + }, +} +``` + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' +import { authAppearance } from './appearance' + +export function LoginPage() { + return ( + + ) +} +``` + + + + +We recommend defining your appearance in a separate file and importing it into your components. + +### 2. Using Your Logo + +You can add your logo to the Auth UI by passing the `logo` prop to any of the components. + + + + +```tsx title="client/LoginPage.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' +import Logo from './logo.png' + +export function LoginPage() { + return ( + + ) +} +``` + + + + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' +import Logo from './logo.png' + +export function LoginPage() { + return ( + + ) +} +``` + + + + +### 3. Social Buttons Layout + +You can change the layout of the social buttons by passing the `socialLayout` prop to any of the components. It can be either `vertical` or `horizontal` (default). + +If we pass in `vertical`: + + + + +```tsx title="client/LoginPage.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +export function LoginPage() { + return ( + + ) +} +``` + + + + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +export function LoginPage() { + return ( + + ) +} +``` + + + + +We get this: + +![Vertical social buttons](/img/authui/vertical_social_buttons.png) + +### Let's Put Everything Together 🪄 + +If we provide the logo and custom colors: + + + + +```ts title="client/appearance.js" +export const appearance = { + colors: { + brand: '#5969b8', // blue + brandAccent: '#de5998', // pink + submitButtonText: 'white', + }, +} +``` + +```tsx title="client/LoginPage.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +import { authAppearance } from './appearance' +import todoLogo from './todoLogo.png' + +export function LoginPage() { + return +} +``` + + + + +```ts title="client/appearance.ts" +import type { CustomizationOptions } from '@wasp/auth/forms/types' + +export const appearance: CustomizationOptions['appearance'] = { + colors: { + brand: '#5969b8', // blue + brandAccent: '#de5998', // pink + submitButtonText: 'white', + }, +} +``` + +```tsx title="client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' + +import { authAppearance } from './appearance' +import todoLogo from './todoLogo.png' + +export function LoginPage() { + return +} +``` + + + + +We get a form looking like this: + +
    + Custom login form +
    diff --git a/web/versioned_docs/version-0.11.8/auth/username-and-pass.md b/web/versioned_docs/version-0.11.8/auth/username-and-pass.md new file mode 100644 index 0000000000..a821824641 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/auth/username-and-pass.md @@ -0,0 +1,652 @@ +--- +title: Username & Password +--- + +import { Required } from '@site/src/components/Tag'; + +Wasp supports username & password authentication out of the box with login and signup flows. It provides you with the server-side implementation and the UI components for the client-side. + +## Setting Up Username & Password Authentication + +To set up username authentication we need to: +1. Enable username authentication in the Wasp file +1. Add the user entity +1. Add the routes and pages +1. Use Auth UI components in our pages + +Structure of the `main.wasp` file we will end up with: + +```wasp title="main.wasp" +// Configuring e-mail authentication +app myApp { + auth: { ... } +} +// Defining User entity +entity User { ... } +// Defining routes and pages +route SignupRoute { ... } +page SignupPage { ... } +// ... +``` + +### 1. Enable Username Authentication + +Let's start with adding the following to our `main.wasp` file: + + + + +```wasp title="main.wasp" {11} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // 1. Specify the user entity (we'll define it next) + userEntity: User, + methods: { + // 2. Enable username authentication + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login" + } +} +``` + + + +```wasp title="main.wasp" {11} +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + // 1. Specify the user entity (we'll define it next) + userEntity: User, + methods: { + // 2. Enable username authentication + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login" + } +} +``` + + + +Read more about the `usernameAndPassword` auth method options [here](#fields-in-the-usernameandpassword-dict). + +### 2. Add the User Entity + +When username authentication is enabled, Wasp expects certain fields in your `userEntity`. Let's add these fields to our `main.wasp` file: + + + + +```wasp title="main.wasp" {4-5} +// 3. Define the user entity +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + // Add your own fields below + // ... +psl=} +``` + + + +```wasp title="main.wasp" {4-5} +// 3. Define the user entity +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + // Add your own fields below + // ... +psl=} +``` + + + +Read more about the `userEntity` fields [here](#userentity-fields). + +### 3. Add the Routes and Pages + +Next, we need to define the routes and pages for the authentication pages. + +Add the following to the `main.wasp` file: + + + + +```wasp title="main.wasp" +// ... +// 4. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.jsx" +} +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { Signup } from "@client/pages/auth.jsx" +} +``` + + + +```wasp title="main.wasp" +// ... +// 4. Define the routes +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { Login } from "@client/pages/auth.tsx" +} +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { Signup } from "@client/pages/auth.tsx" +} +``` + + + +We'll define the React components for these pages in the `client/pages/auth.{jsx,tsx}` file below. + +### 4. Create the Client Pages + +:::info +We are using [Tailwind CSS](https://tailwindcss.com/) to style the pages. Read more about how to add it [here](../project/css-frameworks). +::: + +Let's create a `auth.{jsx,tsx}` file in the `client/pages` folder and add the following to it: + + + + +```tsx title="client/pages/auth.jsx" +import { LoginForm } from "@wasp/auth/forms/Login"; +import { SignupForm } from "@wasp/auth/forms/Signup"; +import { Link } from "react-router-dom"; + +export function Login() { + return ( + + +
    + + Don't have an account yet? go to signup. + +
    + ); +} + +export function Signup() { + return ( + + +
    + + I already have an account (go to login). + +
    + ); +} + +// A layout component to center the content +export function Layout({ children }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ); +} +``` +
    + + +```tsx title="client/pages/auth.tsx" +import { LoginForm } from "@wasp/auth/forms/Login"; +import { SignupForm } from "@wasp/auth/forms/Signup"; +import { Link } from "react-router-dom"; + +export function Login() { + return ( + + +
    + + Don't have an account yet? go to signup. + +
    + ); +} + +export function Signup() { + return ( + + +
    + + I already have an account (go to login). + +
    + ); +} + +// A layout component to center the content +export function Layout({ children }: { children: React.ReactNode }) { + return ( +
    +
    +
    +
    {children}
    +
    +
    +
    + ); +} +``` +
    +
    + +We imported the generated Auth UI components and used them in our pages. Read more about the Auth UI components [here](../auth/ui). + +### Conclusion + +That's it! We have set up username authentication in our app. 🎉 + +Running `wasp db migrate-dev` and then `wasp start` should give you a working app with username authentication. If you want to put some of the pages behind authentication, read the [using auth docs](../auth/overview). + +## Customizing the Auth Flow + +The login and signup flows are pretty standard: they allow the user to sign up and then log in with their username and password. The signup flow validates the username and password and then creates a new user entity in the database. + +Read more about the default username and password validation rules and how to override them in the [using auth docs](../auth/overview). + +If you require more control in your authentication flow, you can achieve that in the following ways: +1. Create your UI and use `signup` and `login` actions. +1. Create your custom sign-up and login [actions](#) which uses the Prisma client, along with your custom code. + +### 1. Using the `signup` and `login` actions + +#### `login()` +An action for logging in the user. + +It takes two arguments: + + - `username: string` + + Username of the user logging in. + + - `password: string` + + Password of the user logging in. + +You can use it like this: + + + + +```jsx title="client/pages/auth.jsx" +// Importing the login action 👇 +import login from '@wasp/auth/login' + +import { useState } from 'react' +import { useHistory } from 'react-router-dom' +import { Link } from 'react-router-dom' + +export function LoginPage() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const history = useHistory() + + async function handleSubmit(event) { + event.preventDefault() + try { + await login(username, password) + history.push('/') + } catch (error) { + setError(error) + } + } + + return ( +
    + {/* ... */} +
    + ); +} +``` +
    + + +```tsx title="client/pages/auth.tsx" +// Importing the login action 👇 +import login from '@wasp/auth/login' + +import { useState } from 'react' +import { useHistory } from 'react-router-dom' +import { Link } from 'react-router-dom' + +export function LoginPage() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const history = useHistory() + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + try { + await login(username, password) + history.push('/') + } catch (error: unknown) { + setError(error as Error) + } + } + + return ( +
    + {/* ... */} +
    + ); +} +``` +
    +
    + +:::note +When using the exposed `login()` function, make sure to implement your redirect on success login logic (e.g. redirecting to home). +::: + +#### `signup()` +An action for signing up the user. This action does not log in the user, you still need to call `login()`. + +It takes one argument: +- `userFields: object` + + It has the following fields: + - `username: string` + + - `password: string` + + :::info + Wasp only stores the auth-related fields of the user entity. Adding extra fields to `userFields` will not have any effect. + + If you need to add extra fields to the user entity, we suggest doing it in a separate step after the user logs in for the first time. + ::: + +You can use it like this: + + + + +```jsx title="client/pages/auth.jsx" +// Importing the signup and login actions 👇 +import signup from '@wasp/auth/signup' +import login from '@wasp/auth/login' + +import { useState } from 'react' +import { useHistory } from 'react-router-dom' +import { Link } from 'react-router-dom' + +export function Signup() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const history = useHistory() + + async function handleSubmit(event) { + event.preventDefault() + try { + await signup({ + username, + password, + }) + await login(username, password) + history.push("/") + } catch (error) { + setError(error) + } + } + + return ( +
    + {/* ... */} +
    + ); +} +``` +
    + + +```tsx title="client/pages/auth.tsx" +// Importing the signup and login actions 👇 +import signup from '@wasp/auth/signup' +import login from '@wasp/auth/login' + +import { useState } from 'react' +import { useHistory } from 'react-router-dom' +import { Link } from 'react-router-dom' + +export function Signup() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const history = useHistory() + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + try { + await signup({ + username, + password, + }) + await login(username, password) + history.push("/") + } catch (error: unknown) { + setError(error as Error) + } + } + + return ( +
    + {/* ... */} +
    + ); +} +``` +
    +
    + +### 2. Creating your custom actions + +The code of your custom sign-up action can look like this: + + + + +```wasp title="main.wasp" +// ... + +action signupUser { + fn: import { signUp } from "@server/auth/signup.js", + entities: [User] +} +``` + + +```js title="src/server/auth/signup.js" +export const signUp = async (args, context) => { + // Your custom code before sign-up. + // ... + + const newUser = context.entities.User.create({ + data: { + username: args.username, + password: args.password // password hashed automatically by Wasp! 🐝 + } + }) + + // Your custom code after sign-up. + // ... + return newUser +} +``` + + + +```wasp title="main.wasp" +// ... + +action signupUser { + fn: import { signUp } from "@server/auth/signup.js", + entities: [User] +} +``` + +```ts title="src/server/auth/signup.ts" +import type { User } from '@wasp/entities' +import type { SignupUser } from '@wasp/actions/types' + +type SignupPayload = Pick + +export const signUp: SignupUser = async (args, context) => { + // Your custom code before sign-up. + // ... + + const newUser = context.entities.User.create({ + data: { + username: args.username, + password: args.password // password hashed automatically by Wasp! 🐝 + } + }) + + // Your custom code after sign-up. + // ... + return newUser +} +``` + + + +## Using Auth + +To read more about how to set up the logout button and how to get access to the logged-in user in our client and server code, read the [using auth docs](../auth/overview). + +## API Reference + +### `userEntity` fields + + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login" + } +} + +// Wasp requires the `userEntity` to have at least the following fields +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String +psl=} +``` + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login" + } +} + +// Wasp requires the `userEntity` to have at least the following fields +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String +psl=} +``` + + + +Username & password auth requires that `userEntity` specified in `auth` contains: + +- `username` field of type `String` +- `password` field of type `String` + +### Fields in the `usernameAndPassword` dict + + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login" + } +} +// ... +``` + + + +```wasp title="main.wasp" +app myApp { + wasp: { + version: "^0.11.0" + }, + title: "My App", + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login" + } +} +// ... +``` + + + +:::info +`usernameAndPassword` dict doesn't have any options at the moment. +::: + +You can read about the rest of the `auth` options in the [using auth](../auth/overview) section of the docs. diff --git a/web/versioned_docs/version-0.11.8/contact.md b/web/versioned_docs/version-0.11.8/contact.md new file mode 100644 index 0000000000..5ddbedafbc --- /dev/null +++ b/web/versioned_docs/version-0.11.8/contact.md @@ -0,0 +1,5 @@ +--- +title: Contact +--- + +You can find us on [Discord](https://discord.gg/rzdnErX) or you can reach out to us via email at hi@wasp-lang.dev. diff --git a/web/versioned_docs/version-0.11.8/contributing.md b/web/versioned_docs/version-0.11.8/contributing.md new file mode 100644 index 0000000000..2325a17861 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/contributing.md @@ -0,0 +1,19 @@ +--- +title: Contributing +sidebar_label: Contributing +slug: /contributing +--- + +import DiscordLink from '@site/blog/components/DiscordLink'; + +Any way you want to contribute is a good way, and we'd be happy to meet you! A single entry point for all contributors is the [CONTRIBUTING.md](https://github.com/wasp-lang/wasp/blob/main/CONTRIBUTING.md) file in our Github repo. All the requirements and instructions are there, so please check [CONTRIBUTING.md](https://github.com/wasp-lang/wasp/blob/main/CONTRIBUTING.md) for more details. + +Some side notes to make your journey easier: + +1. Join us on and let's talk! We can discuss language design, new/existing features, and weather, or you can tell us how you feel about Wasp :). + +2. Wasp's compiler is built with Haskell. That means you'll need to be somewhat familiar with this language if you'd like to contribute to the compiler itself. But Haskell is just a part of Wasp, and you can contribute to lot of parts that require web dev skills, either by coding or by suggesting how to improve Wasp and its design as a web framework. If you don't have Haskell knowledge (or any dev experience at all) - no problem. There are a lot of JS-related tasks and documentation updates as well! + +3. If there's something you'd like to bring to our attention, go to [docs GitHub repo](https://github.com/wasp-lang/wasp) and make an issue/PR! + +Happy hacking! diff --git a/web/versioned_docs/version-0.11.8/data-model/backends.md b/web/versioned_docs/version-0.11.8/data-model/backends.md new file mode 100644 index 0000000000..672f2998cc --- /dev/null +++ b/web/versioned_docs/version-0.11.8/data-model/backends.md @@ -0,0 +1,452 @@ +--- +title: Databases +--- + +import { Required } from '@site/src/components/Tag' + +[Entities](../data-model/entities.md), [Operations](../data-model/operations/overview) and [Automatic CRUD](../data-model/crud.md) together make a high-level interface for working with your app's data. Still, all that data has to live somewhere, so let's see how Wasp deals with databases. + +## Supported Database Backends + +Wasp supports multiple database backends. We'll list and explain each one. + +### SQLite + +The default database Wasp uses is [SQLite](https://www.sqlite.org/index.html). + +SQLite is a great way for getting started with a new project because it doesn't require any configuration, but Wasp can only use it in development. Once you want to deploy your Wasp app to production, you'll need to switch to PostgreSQL and stick with it. + +Fortunately, migrating from SQLite to PostgreSQL is pretty simple, and we have [a guide](#migrating-from-sqlite-to-postgresql) to help you. + +### PostgreSQL + +[PostgreSQL](https://www.postgresql.org/) is the most advanced open source database and the fourth most popular database overall. +It's been in active development for 20 years. +Therefore, if you're looking for a battle-tested database, look no further. + +To use Wasp with PostgreSQL, you'll have to ensure a database instance is running during development. Wasp needs access to your database for commands such as `wasp start` or `wasp db migrate-dev` and expects to find a connection string in the `DATABASE_URL` environment variable. + +We cover all supported ways of connecting to a database in [the next section](#connecting-to-a-database). + +### Migrating from SQLite to PostgreSQL + +To run your Wasp app in production, you'll need to switch from SQLite to PostgreSQL. + +1. Set the `app.db.system` field to PostgreSQL. + +```wasp title=main.wasp +app MyApp { + title: "My app", + // ... + db: { + system: PostgreSQL, + // ... + } +} +``` + +2. Delete all the old migrations, since they are SQLite migrations and can't be used with PostgreSQL, as well as the SQLite database by running [`wasp clean`](https://wasp-lang.dev/docs/general/cli#project-commands): + +```bash +rm -r migrations/ +wasp clean +``` + +3. Ensure your new database is running (check the [section on connecing to a database](#connecting-to-a-database) to see how). Leave it running, since we need it for the next step. +4. In a different terminal, run `wasp db migrate-dev` to apply the changes and create a new initial migration. +5. That is it, you are all done! + +## Connecting to a Database + +Assuming you're not using SQLite, Wasp offers two ways of connecting your app to a database instance: + +1. A ready-made dev database that requires minimal setup and is great for quick prototyping. +2. A "real" database Wasp can connect to and use in production. + +### Using the Dev Database Provided by Wasp + +The command `wasp start db` will start a default PostgreSQL dev database for you. + +Your Wasp app will automatically connect to it, just keep `wasp start db` running in the background. +Also, make sure that: + +- You have [Docker installed](https://www.docker.com/get-started/) and in `PATH`. +- The port `5432` isn't taken. + +### Connecting to an existing database + +If you want to spin up your own dev database (or connect to an external one), you can tell Wasp about it using the `DATABASE_URL` environment variable. Wasp will use the value of `DATABASE_URL` as a connection string. + +The easiest way to set the necessary `DATABASE_URL` environment variable is by adding it to the [.env.server](../project/env-vars) file in the root dir of your Wasp project (if that file doesn't yet exist, create it). + +Alternatively, you can set it inline when running `wasp` (this applies to all environment variables): + +```bash +DATABASE_URL= wasp ... +``` + +This trick is useful for running a certain `wasp` command on a specific database. +For example, you could do: + +```bash +DATABASE_URL= wasp db seed myProductionSeed +``` + +This command seeds the data for a fresh staging or production database. +To more precisely understand how seeding works, keep reading. + +## Seeding the Database + +**Database seeding** is a term used for populating the database with some initial data. + +Seeding is most commonly used for two following scenarios: + +1. To put the development database into a state convenient for working and testing. +2. To initialize any database (`dev`, `staging`, or `prod`) with essential data it requires to operate. + For example, populating the Currency table with default currencies, or the Country table with all available countries. + +### Writing a Seed Function + +You can define as many **seed functions** as you want in an array under the `app.db.seeds` field: + + + + +```wasp title=main.wasp +app MyApp { + // ... + db: { + // ... + seeds: [ + import { devSeedSimple } from "@server/dbSeeds.js", + import { prodSeed } from "@server/dbSeeds.js" + ] + } +} +``` + + + + +```wasp title=main.wasp +app MyApp { + // ... + db: { + // ... + seeds: [ + import { devSeedSimple } from "@server/dbSeeds.js", + import { prodSeed } from "@server/dbSeeds.js" + ] + } +} +``` + + + + +Each seed function must be an async function that takes one argument, `prismaClient`, which is a [Prisma Client](https://www.prisma.io/docs/concepts/components/prisma-client/crud) instance used to interact with the database. +This is the same Prisma Client instance that Wasp uses internally and thus includes all of the usual features (e.g., password hashing). + +Since a seed function falls under server-side code, it can import other server-side functions. This is convenient because you might want to seed the database using Actions. + +Here's an example of a seed function that imports an Action: + + + + +```js +import { createTask } from "./actions.js"; + +export const devSeedSimple = async (prismaClient) => { + const user = await createUser(prismaClient, { + username: "RiuTheDog", + password: "bark1234", + }); + + await createTask( + { description: "Chase the cat" }, + { user, entities: { Task: prismaClient.task } } + ); +}; + +async function createUser(prismaClient, data) { + const { password, ...newUser } = await prismaClient.user.create({ data }); + return newUser; +} +``` + + + + +```ts +import { createTask } from "./actions.js"; +import { User } from "@wasp/entities"; +import { PrismaClient } from "@prisma/client"; + +type SanitizedUser = Omit; + +export const devSeedSimple = async (prismaClient: PrismaClient) => { + const user = await createUser(prismaClient, { + username: "RiuTheDog", + password: "bark1234", + }); + + await createTask( + { description: "Chase the cat", isDone: false }, + { user, entities: { Task: prismaClient.task } } + ); +}; + +async function createUser( + prismaClient: PrismaClient, + data: Pick +): Promise { + const { password, ...newUser } = await prismaClient.user.create({ data }); + return newUser; +} +``` + + + + +### Running seed functions + +Run the command `wasp db seed` and Wasp will ask you which seed function you'd like to run (if you've defined more than one). + +Alternatively, run the command `wasp db seed ` to choose a specific seed function right away, for example: + +``` +wasp db seed devSeedSimple +``` + +Check the [API Reference](#cli-commands-for-seeding-the-database) for more details on these commands. + +:::tip +You'll often want to call `wasp db seed` right after you run `wasp db reset`, as it makes sense to fill the database with initial data after clearing it. +::: + +## Prisma Configuration + +Wasp uses [Prisma](https://www.prisma.io/) to interact with the database. Prisma is a "Next-generation Node.js and TypeScript ORM" that provides a type-safe API for working with your database. + +### Prisma Preview Features + +Prisma is still in active development and some of its features are not yet stable. To use them, you have to enable them in the `app.db.prisma.clientPreviewFeatures` field: + +```wasp title="main.wasp" +app MyApp { + // ... + db: { + system: PostgreSQL, + prisma: { + clientPreviewFeatures: ["postgresqlExtensions"] + } + } +} +``` + + + +Read more about Prisma preview features in the [Prisma docs](https://www.prisma.io/docs/concepts/components/preview-features/client-preview-features). + + +### PostgreSQL Extensions + +PostgreSQL supports [extensions](https://www.postgresql.org/docs/current/contrib.html) that add additional functionality to the database. For example, the [hstore](https://www.postgresql.org/docs/13/hstore.html) extension adds support for storing key-value pairs in a single column. + +To use PostgreSQL extensions with Prisma, you have to enable them in the `app.db.prisma.dbExtensions` field: + +```wasp title="main.wasp" +app MyApp { + // ... + db: { + system: PostgreSQL, + prisma: { + clientPreviewFeatures: ["postgresqlExtensions"] + dbExtensions: [ + { name: "hstore", schema: "myHstoreSchema" }, + { name: "pg_trgm" }, + { name: "postgis", version: "2.1" }, + ] + } + } +} +``` + + + +Read more about PostgreSQL configuration in Wasp in the [API Reference](#the-appdb-field). + + +## API Reference + +You can tell Wasp which database to use in the `app` declaration's `db` field: + +### The `app.db` Field + +Here's an example that uses the `app.db` field to its full potential: + + + + +```wasp title=main.wasp +app MyApp { + title: "My app", + // ... + db: { + system: PostgreSQL, + seeds: [ + import devSeed from "@server/dbSeeds.js" + ], + prisma: { + clientPreviewFeatures: ["extendedWhereUnique"] + } + } +} +``` + + + + +```wasp title=main.wasp +app MyApp { + title: "My app", + // ... + db: { + system: PostgreSQL, + seeds: [ + import devSeed from "@server/dbSeeds.js" + ], + prisma: { + clientPreviewFeatures: ["extendedWhereUnique"] + } + } +} +``` + + + + +`app.db` is a dictionary with the following fields (all fields are optional): + +- `system: DbSystem` + + The database system Wasp should use. It can be either PostgreSQL or SQLite. + The default value for the field is SQLite (this default value also applies if the entire `db` field is left unset). + Whenever you modify the `db.system` field, make sure to run `wasp db migrate-dev` to apply the changes. + +- `seeds: [ServerImport]` + + Defines the seed functions you can use with the `wasp db seed` command to seed your database with initial data. + Read the [Seeding section](#seeding-the-database) for more details. + +- `prisma: PrismaOptions` + + Additional configuration for Prisma. + + ```wasp title="main.wasp" + app MyApp { + // ... + db: { + // ... + prisma: { + clientPreviewFeatures: ["postgresqlExtensions"], + dbExtensions: [ + { name: "hstore", schema: "myHstoreSchema" }, + { name: "pg_trgm" }, + { name: "postgis", version: "2.1" }, + ] + } + } + } + ``` + + It's a dictionary with the following fields: + + - `clientPreviewFeatures : [string]` + + Allows you to define [Prisma client preview features](https://www.prisma.io/docs/concepts/components/preview-features/client-preview-features), like for example, `"postgresqlExtensions"`. + + - `dbExtensions: DbExtension[]` + + It allows you to define PostgreSQL extensions that should be enabled for your database. Read more about [PostgreSQL extensions in Prisma](https://www.prisma.io/docs/concepts/components/prisma-schema/postgresql-extensions). + + For each extension you define a dict with the following fields: + + - `name: string` + + The name of the extension you would normally put in the Prisma file. + + ```prisma title="schema.prisma" + extensions = [hstore(schema: "myHstoreSchema"), pg_trgm, postgis(version: "2.1")] + // 👆 Extension name + ``` + + - `map: string` + + It sets the `map` argument of the extension. Explanation for the field from the Prisma docs: + > This is the database name of the extension. If this argument is not specified, the name of the extension in the Prisma schema must match the database name. + + - `schema: string` + + It sets the `schema` argument of the extension. Explanation for the field from the Prisma docs: + > This is the name of the schema in which to activate the extension's objects. If this argument is not specified, the current default object creation schema is used. + + - `version: string` + + It sets the `version` argument of the extension. Explanation for the field from the Prisma docs: + > This is the version of the extension to activate. If this argument is not specified, the value given in the extension's control file is used. + +### CLI Commands for Seeding the Database + +Use one of the following commands to run the seed functions: + +- `wasp db seed` + + If you've only defined a single seed function, this command runs it. If you've defined multiple seed functions, it asks you to choose one interactively. + +- `wasp db seed ` + + This command runs the seed function with the specified name. The name is the identifier used in its `import` expression in the `app.db.seeds` list. + For example, to run the seed function `devSeedSimple` which was defined like this: + + + + + ```wasp title=main.wasp + app MyApp { + // ... + db: { + // ... + seeds: [ + // ... + import { devSeedSimple } from "@server/dbSeeds.js", + ] + } + } + ``` + + + + + ```wasp title=main.wasp + app MyApp { + // ... + db: { + // ... + seeds: [ + // ... + import { devSeedSimple } from "@server/dbSeeds.js", + ] + } + } + ``` + + + + + Use the following command: + + ``` + wasp db seed devSeedSimple + ``` diff --git a/web/versioned_docs/version-0.11.8/data-model/crud.md b/web/versioned_docs/version-0.11.8/data-model/crud.md new file mode 100644 index 0000000000..42514b09b3 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/data-model/crud.md @@ -0,0 +1,749 @@ +--- +title: Automatic CRUD +--- + +import { Required } from '@site/src/components/Tag'; +import { ShowForTs } from '@site/src/components/TsJsHelpers'; +import ImgWithCaption from '@site/blog/components/ImgWithCaption' + +If you have a lot of experience writing full-stack apps, you probably ended up doing some of the same things many times: listing data, adding data, editing it, and deleting it. + +Wasp makes handling these boring bits easy by offering a higher-level concept called Automatic CRUD. + +With a single declaration, you can tell Wasp to automatically generate server-side logic (i.e., Queries and Actions) for creating, reading, updating and deleting [Entities](../data-model/entities). As you update definitions for your Entities, Wasp automatically regenerates the backend logic. + +:::caution Early preview +This feature is currently in early preview and we are actively working on it. Read more about [our plans](#future-of-crud-operations-in-wasp) for CRUD operations. +::: + +## Overview + +Imagine we have a `Task` entity and we want to enable CRUD operations for it. + +```wasp title="main.wasp" +entity Task {=psl + id Int @id @default(autoincrement()) + description String + isDone Boolean +psl=} +``` + +We can then define a new `crud` called `Tasks`. + +We specify to use the `Task` entity and we enable the `getAll`, `get`, `create` and `update` operations (let's say we don't need the `delete` operation). + +```wasp title="main.wasp" +crud Tasks { + entity: Task, + operations: { + getAll: { + isPublic: true, // by default only logged in users can perform operations + }, + get: {}, + create: { + overrideFn: import { createTask } from "@server/tasks.js", + }, + update: {}, + }, +} +``` + +1. It uses default implementation for `getAll`, `get`, and `update`, +2. ... while specifying a custom implementation for `create`. +3. `getAll` will be public (no auth needed), while the rest of the operations will be private. + +Here's what it looks like when visualized: + + + +We can now use the CRUD queries and actions we just specified in our client code. + +Keep reading for an example of Automatic CRUD in action, or skip ahead for the [API Reference](#api-reference) + +## Example: A Simple TODO App + +Let's create a full-app example that uses automatic CRUD. We'll stick to using the `Task` entity from the previous example, but we'll add a `User` entity and enable [username and password](../auth/username-and-pass) based auth. + + + +### Creating the App + +We can start by running `wasp new tasksCrudApp` and then adding the following to the `main.wasp` file: + +```wasp title="main.wasp" +app tasksCrudApp { + wasp: { + version: "^0.11.0" + }, + title: "Tasks Crud App", + + // We enabled auth and set the auth method to username and password + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + tasks Task[] +psl=} + +// We defined a Task entity on which we'll enable CRUD later on +entity Task {=psl + id Int @id @default(autoincrement()) + description String + isDone Boolean + userId Int + user User @relation(fields: [userId], references: [id]) +psl=} + +// Tasks app routes +route RootRoute { path: "/", to: MainPage } +page MainPage { + component: import { MainPage } from "@client/MainPage.jsx", + authRequired: true, +} + +route LoginRoute { path: "/login", to: LoginPage } +page LoginPage { + component: import { LoginPage } from "@client/LoginPage.jsx", +} + +route SignupRoute { path: "/signup", to: SignupPage } +page SignupPage { + component: import { SignupPage } from "@client/SignupPage.jsx", +} +``` + +We can then run `wasp db migrate-dev` to create the database and run the migrations. + +### Adding CRUD to the `Task` Entity ✨ + +Let's add the following `crud` declaration to our `main.wasp` file: + +```wasp title="main.wasp" +// ... + +crud Tasks { + entity: Task, + operations: { + getAll: {}, + create: { + overrideFn: import { createTask } from "@server/tasks.js", + }, + }, +} +``` + +You'll notice that we enabled only `getAll` and `create` operations. This means that only these operations will be available. + +We also overrode the `create` operation with a custom implementation. This means that the `create` operation will not be generated, but instead, the `createTask` function from `@server/tasks.js` will be used. + +### Our Custom `create` Operation + +Here's the `src/server/tasks.{js,ts}` file: + + + + +```js title=src/server/tasks.js {15-20} +import HttpError from '@wasp/core/HttpError.js' + +export const createTask = async (args, context) => { + if (!context.user) { + throw new HttpError(401, 'User not authenticated.') + } + + const { description, isDone } = args + const { Task } = context.entities + + return await Task.create({ + data: { + description, + isDone, + // Connect the task to the user that is creating it + user: { + connect: { + id: context.user.id, + }, + }, + }, + }) +} +``` + + + + +```ts title=src/server/tasks.ts {23-28} +import type { CreateAction } from '@wasp/crud/Tasks' +import type { Task } from '@wasp/entities' +import HttpError from '@wasp/core/HttpError.js' + +type CreateTaskInput = { description: string; isDone: boolean } + +export const createTask: CreateAction = async ( + args, + context +) => { + if (!context.user) { + throw new HttpError(401, 'User not authenticated.') + } + + const { description, isDone } = args + const { Task } = context.entities + + return await Task.create({ + data: { + description, + isDone, + // Connect the task to the user that is creating it + user: { + connect: { + id: context.user.id, + }, + }, + }, + }) +} +``` + + + + +We made a custom `create` operation because we want to make sure that the task is connected to the user that is creating it. +Automatic CRUD doesn't support this by default (yet!). +Read more about the default implementations [here](#declaring-a-crud-with-default-options). + +### Using the Generated CRUD Operations on the Client + +And let's use the generated operations in our client code: + + + + +```jsx title="pages/MainPage.jsx" +// highlight-next-line +import { Tasks } from '@wasp/crud/Tasks' +import { useState } from 'react' + +export const MainPage = () => { + // highlight-next-line + const { data: tasks, isLoading, error } = Tasks.getAll.useQuery() + // highlight-next-line + const createTask = Tasks.create.useAction() + const [taskDescription, setTaskDescription] = useState('') + + function handleCreateTask() { + createTask({ description: taskDescription, isDone: false }) + setTaskDescription('') + } + + if (isLoading) return
    Loading...
    + if (error) return
    Error: {error.message}
    + return ( +
    +
    + setTaskDescription(e.target.value)} + /> + +
    +
      + {tasks.map((task) => ( +
    • {task.description}
    • + ))} +
    +
    + ) +} +``` + +
    + + +```tsx title="pages/MainPage.tsx" +// highlight-next-line +import { Tasks } from '@wasp/crud/Tasks' +import { useState } from 'react' + +export const MainPage = () => { + // highlight-next-line + // Thanks to full-stack type safety, all payload types are inferred + // highlight-next-line + // automatically + // highlight-next-line + const { data: tasks, isLoading, error } = Tasks.getAll.useQuery() + // highlight-next-line + const createTask = Tasks.create.useAction() + const [taskDescription, setTaskDescription] = useState('') + + function handleCreateTask() { + createTask({ description: taskDescription, isDone: false }) + setTaskDescription('') + } + + if (isLoading) return
    Loading...
    + if (error) return
    Error: {error.message}
    + return ( +
    +
    + setTaskDescription(e.target.value)} + /> + +
    +
      + {tasks.map((task) => ( +
    • {task.description}
    • + ))} +
    +
    + ) +} +``` + +
    +
    + +And here are the login and signup pages, where we are using Wasp's [Auth UI](../auth/ui) components: + + + + +```jsx title="src/client/LoginPage.jsx" +import { LoginForm } from '@wasp/auth/forms/Login' +import { Link } from 'react-router-dom' + +export function LoginPage() { + return ( +
    + +
    + Create an account +
    +
    + ) +} +``` + +
    + + +```tsx title="src/client/LoginPage.tsx" +import { LoginForm } from '@wasp/auth/forms/Login' +import { Link } from 'react-router-dom' + +export function LoginPage() { + return ( +
    + +
    + Create an account +
    +
    + ) +} +``` + +
    +
    + + + + +```jsx title="src/client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' + +export function SignupPage() { + return ( +
    + +
    + ) +} +``` + +
    + + +```tsx title="src/client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' + +export function SignupPage() { + return ( +
    + +
    + ) +} +``` + +
    +
    + +That's it. You can now run `wasp start` and see the app in action. ⚡️ + +You should see a login page and a signup page. After you log in, you should see a page with a list of tasks and a form to create new tasks. + +## Future of CRUD Operations in Wasp + +CRUD operations currently have a limited set of knowledge about the business logic they are implementing. + +- For example, they don't know that a task should be connected to the user that is creating it. This is why we had to override the `create` operation in the example above. +- Another thing: they are not aware of the authorization rules. For example, they don't know that a user should not be able to create a task for another user. In the future, we will be adding role-based authorization to Wasp, and we plan to make CRUD operations aware of the authorization rules. +- Another issue is input validation and sanitization. For example, we might want to make sure that the task description is not empty. + +CRUD operations are a mechanism for getting a backend up and running quickly, but it depends on the information it can get from the Wasp app. The more information that it can pick up from your app, the more powerful it will be out of the box. + +We plan on supporting CRUD operations and growing them to become the easiest way to create your backend. Follow along on [this GitHub issue](https://github.com/wasp-lang/wasp/issues/1253) to see how we are doing. + +## API Reference + +CRUD declaration work on top of existing entity declaration. We'll fully explore the API using two examples: + +1. A basic CRUD declaration that relies on default options. +2. A more involved CRUD declaration that uses extra options and overrides. + +### Declaring a CRUD With Default Options + +If we create CRUD operations for an entity named `Task`, like this: + + + + +```wasp title="main.wasp" +crud Tasks { // crud name here is "Tasks" + entity: Task, + operations: { + get: {}, + getAll: {}, + create: {}, + update: {}, + delete: {}, + }, +} +``` + +Wasp will give you the following default implementations: + +**get** - returns one entity based on the `id` field + +```js +// ... +// Wasp uses the field marked with `@id` in Prisma schema as the id field. +return Task.findUnique({ where: { id: args.id } }) +``` + +**getAll** - returns all entities + +```js +// ... + +// If the operation is not public, Wasp checks if an authenticated user +// is making the request. + +return Task.findMany() +``` + +**create** - creates a new entity + +```js +// ... +return Task.create({ data: args.data }) +``` + +**update** - updates an existing entity + +```js +// ... +// Wasp uses the field marked with `@id` in Prisma schema as the id field. +return Task.update({ where: { id: args.id }, data: args.data }) +``` + +**delete** - deletes an existing entity + +```js +// ... +// Wasp uses the field marked with `@id` in Prisma schema as the id field. +return Task.delete({ where: { id: args.id } }) +``` + + + + +```wasp title="main.wasp" +crud Tasks { // crud name here is "Tasks" + entity: Task, + operations: { + get: {}, + getAll: {}, + create: {}, + update: {}, + delete: {}, + }, +} +``` + +Wasp will give you the following default implementations: + +**get** - returns one entity based on the `id` field + +```ts +// ... +// Wasp uses the field marked with `@id` in Prisma schema as the id field. +return Task.findUnique({ where: { id: args.id } }) +``` + +**getAll** - returns all entities + +```ts +// ... + +// If the operation is not public, Wasp checks if an authenticated user +// is making the request. + +return Task.findMany() +``` + +**create** - creates a new entity + +```ts +// ... +return Task.create({ data: args.data }) +``` + +**update** - updates an existing entity + +```ts +// ... +// Wasp uses the field marked with `@id` in Prisma schema as the id field. +return Task.update({ where: { id: args.id }, data: args.data }) +``` + +**delete** - deletes an existing entity + +```ts +// ... +// Wasp uses the field marked with `@id` in Prisma schema as the id field. +return Task.delete({ where: { id: args.id } }) +``` + + + + +:::info Current Limitations +In the default `create` and `update` implementations, we are saving all of the data that the client sends to the server. This is not always desirable, i.e. in the case when the client should not be able to modify all of the data in the entity. + +[In the future](#future-of-crud-operations-in-wasp), we are planning to add validation of action input, where only the data that the user is allowed to change will be saved. + +For now, the solution is to provide an override function. You can override the default implementation by using the `overrideFn` option and implementing the validation logic yourself. + +::: + +### Declaring a CRUD With All Available Options + +Here's an example of a more complex CRUD declaration: + + + + +```wasp title="main.wasp" +crud Tasks { // crud name here is "Tasks" + entity: Task, + operations: { + getAll: { + isPublic: true, // optional, defaults to false + }, + get: {}, + create: { + overrideFn: import { createTask } from "@server/tasks.js", // optional + }, + update: {}, + }, +} +``` + + + + +```wasp title="main.wasp" +crud Tasks { // crud name here is "Tasks" + entity: Task, + operations: { + getAll: { + isPublic: true, // optional, defaults to false + }, + get: {}, + create: { + overrideFn: import { createTask } from "@server/tasks.js", // optional + }, + update: {}, + }, +} +``` + + + + +The CRUD declaration features the following fields: + +- `entity: Entity` + + The entity to which the CRUD operations will be applied. + +- `operations: { [operationName]: CrudOperationOptions }` + + The operations to be generated. The key is the name of the operation, and the value is the operation configuration. + + - The possible values for `operationName` are: + - `getAll` + - `get` + - `create` + - `update` + - `delete` + - `CrudOperationOptions` can have the following fields: + - `isPublic: bool` - Whether the operation is public or not. If it is public, no auth is required to access it. If it is not public, it will be available only to authenticated users. Defaults to `false`. + - `overrideFn: ServerImport` - The import statement of the optional override implementation in Node.js. + +#### Defining the overrides + +Like with actions and queries, you can define the implementation in a Javascript/Typescript file. The overrides are functions that take the following arguments: + +- `args` + + The arguments of the operation i.e. the data sent from the client. + +- `context` + + Context contains the `user` making the request and the `entities` object with the entity that's being operated on. + + + +You can also import types for each of the functions you want to override from `@wasp/crud/{crud name}`. The available types are: + +- `GetAllQuery` +- `GetQuery` +- `CreateAction` +- `UpdateAction` +- `DeleteAction` + +If you have a CRUD named `Tasks`, you would import the types like this: + +```ts +import type { + GetAllQuery, + GetQuery, + CreateAction, + UpdateAction, + DeleteAction, +} from '@wasp/crud/Tasks' + +// Each of the types is a generic type, so you can use it like this: +export const getAllOverride: GetAllQuery = async ( + args, + context +) => { + // ... +} +``` + + + +For a usage example, check the [example guide](../data-model/crud#adding-crud-to-the-task-entity-). + +#### Using the CRUD operations in client code + +On the client, you import the CRUD operations from `@wasp/crud/{crud name}`. The names of the imports are the same as the names of the operations. For example, if you have a CRUD called `Tasks`, you would import the operations like this: + + + + +```jsx title="SomePage.jsx" +import { Tasks } from '@wasp/crud/Tasks' +``` + + + + +```tsx title="SomePage.tsx" +import { Tasks } from '@wasp/crud/Tasks' +``` + + + + +You can then access the operations like this: + + + + +```jsx title="SomePage.jsx" +const { data } = Tasks.getAll.useQuery() +const { data } = Tasks.get.useQuery({ id: 1 }) +const createAction = Tasks.create.useAction() +const updateAction = Tasks.update.useAction() +const deleteAction = Tasks.delete.useAction() +``` + + + + +```tsx title="SomePage.tsx" +const { data } = Tasks.getAll.useQuery() +const { data } = Tasks.get.useQuery({ id: 1 }) +const createAction = Tasks.create.useAction() +const updateAction = Tasks.update.useAction() +const deleteAction = Tasks.delete.useAction() +``` + + + + +All CRUD operations are implemented with [Queries and Actions](../data-model/operations/overview) under the hood, which means they come with all the features you'd expect (e.g., automatic SuperJSON serialization, full-stack type safety when using TypeScript) + +--- + +Join our **community** on [Discord](https://discord.com/invite/rzdnErX), where we chat about full-stack web stuff. Join us to see what we are up to, share your opinions or get help with CRUD operations. diff --git a/web/versioned_docs/version-0.11.8/data-model/entities.md b/web/versioned_docs/version-0.11.8/data-model/entities.md new file mode 100644 index 0000000000..23843da178 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/data-model/entities.md @@ -0,0 +1,105 @@ +--- +title: Entities +--- + +Entities are the foundation of your app's data model. In short, an Entity defines a model in your database. + +Wasp uses the excellent [Prisma ORM](https://www.prisma.io/) to implement all database functionality and occasionally enhances it with a thin abstraction layer. +Wasp Entities directly correspond to [Prisma's data model](https://www.prisma.io/docs/concepts/components/prisma-schema/data-model). Still, you don't need to be familiar with Prisma to effectively use Wasp, as it comes with a simple API wrapper for working with Prisma's core features. + +The only requirement for defining Wasp Entities is familiarity with the **_Prisma Schema Language (PSL)_**, a simple definition language explicitly created for defining models in Prisma. +The language is declarative and very intuitive. We'll also go through an example later in the text, so there's no need to go and thoroughly learn it right away. Still, if you're curious, look no further than Prisma's official documentation: + +- [Basic intro and examples](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema) +- [A more exhaustive language specification](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference) + +## Defining an Entity + +As mentioned, an `entity` declaration represents a database model. + +Each `Entity` declaration corresponds 1-to-1 to [Prisma's data model](https://www.prisma.io/docs/concepts/components/prisma-schema/data-model). Here's how you could define an Entity that represents a Task: + + + + +```wasp +entity Task {=psl + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) +psl=} +``` + + + + +```wasp +entity Task {=psl + id Int @id @default(autoincrement()) + description String + isDone Boolean @default(false) +psl=} +``` + + + + +Let's go through this declaration in detail: + +- `entity Task` - This tells Wasp that we wish to define an Entity (i.e., database model) called `Task`. Wasp automatically creates a table called `tasks`. +- `{=psl ... psl=}` - Wasp treats everything that comes between the two `psl` tags as [PSL (Prisma Schema Language)](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema). + +The above PSL definition tells Wasp to create a table for storing Tasks where each task has three fields (i.e., the `tasks` table has three columns): + +- `id` - An integer value serving as a primary key. The database automatically generates it by incrementing the previously generated `id`. +- `description` - A string value for storing the task's description. +- `isDone` - A boolean value indicating the task's completion status. If you don't set it when creating a new task, the database sets it to `false` by default. + +### Working with Entities + +Let's see how you can define and work with Wasp Entities: + +1. Create/update some Entities in your `.wasp` file. +2. Run `wasp db migrate-dev`. This command syncs the database model with the Entity definitions in your `.wasp` file. It does this by creating migration scripts. +3. Migration scripts are automatically placed in the `migrations/` folder. Make sure to commit this folder into version control. +4. Use Wasp's JavasScript API to work with the database when implementing Operations (we'll cover this in detail when we talk about [operations](../data-model/operations/overview)). + +#### Using Entities in Operations + +Most of the time, you will be working with Entities within the context of [Operations (Queries & Actions)](../data-model/operations/overview). We'll see how that's done on the next page. + +#### Using Entities directly + +If you need more control, you can directly interact with Entities by importing and using the [Prisma Client](https://www.prisma.io/docs/concepts/components/prisma-client/crud). We recommend sticking with conventional Wasp-provided mechanisms, only resorting to directly using the Prisma client only if you need a feature Wasp doesn't provide. + +You can only use the Prisma Client in your Wasp server code. You can import it like this: + + + +```js +import prismaClient from '@wasp/dbClient'` + +prismaClient.task.create({ + description: "Read the Entities doc", + isDone: true // almost :) +}) +``` + + + + +```ts +import prismaClient from '@wasp/dbClient'` + +prismaClient.task.create({ + description: "Read the Entities doc", + isDone: true // almost :) +}) +``` + + + + +### Next steps + +Now that we've seen how to define Entities that represent Wasp's core data model, we'll see how to make the most of them in other parts of Wasp. Keep reading to learn all about Wasp Operations! diff --git a/web/versioned_docs/version-0.11.8/data-model/operations/_superjson-note.md b/web/versioned_docs/version-0.11.8/data-model/operations/_superjson-note.md new file mode 100644 index 0000000000..9890d512c2 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/data-model/operations/_superjson-note.md @@ -0,0 +1,14 @@ +import { ShowForTs } from '@site/src/components/TsJsHelpers'; + +:::tip +Wasp uses [superjson](https://github.com/blitz-js/superjson) under the hood. +This means you're not limited to only sending and receiving JSON payloads. + +You can send and receive any superjson-compatible payload (like Dates, Sets, Lists, circular references, etc.) and let Wasp handle the (de)serialization. + + + +As long as you're annotating your Queries with the correct automatically generated types, TypeScript ensures your payloads are valid (i.e., Wasp knows how to serialize and deserialize them). + + +::: diff --git a/web/versioned_docs/version-0.11.8/data-model/operations/actions.md b/web/versioned_docs/version-0.11.8/data-model/operations/actions.md new file mode 100644 index 0000000000..fb0b378fd4 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/data-model/operations/actions.md @@ -0,0 +1,875 @@ +--- +title: Actions +--- + +import { Required } from '@site/src/components/Tag'; +import { ShowForTs } from '@site/src/components/TsJsHelpers'; +import SuperjsonNote from './\_superjson-note.md'; + +We'll explain what Actions are and how to use them. If you're looking for a detailed API specification, skip ahead to the [API Reference](#api-reference). + +Actions are quite similar to [Queries](../../data-model/operations/queries.md), but with a key distinction: Actions are designed to modify and add data, while Queries are solely for reading data. Examples of Actions include adding a comment to a blog post, liking a video, or updating a product's price. + +Actions and Queries work together to keep data caches up-to-date. + +:::tip +Actions are almost identical to Queries in terms of their API. +Therefore, if you're already familiar with Queries, you might find reading the entire guide repetitive. + +We instead recommend skipping ahead and only reading [the differences between Queries and Actions](#differences-between-queries-and-actions), and consulting the [API Reference](#api-reference) as needed. +::: + +## Working with Actions + +Actions are declared in Wasp and implemented in NodeJS. Wasp runs Actions within the server's context, but it also generates code that allows you to call them from anywhere in your code (either client or server) using the same interface. + +This means you don't have to worry about building an HTTP API for the Action, managing server-side request handling, or even dealing with client-side response handling and caching. +Instead, just focus on developing the business logic inside your Action, and let Wasp handle the rest! + +To create an Action, you need to: + +1. Declare the Action in Wasp using the `action` declaration. +2. Implement the Action's NodeJS functionality. + +Once these two steps are completed, you can use the Action from anywhere in your code. + +### Declaring Actions + +To create an Action in Wasp, we begin with an `action` declaration. Let's declare two Actions - one for creating a task, and another for marking tasks as done: + + + + +```wasp title="main.wasp" +// ... + +action createTask { + fn: import { createTask } from "@server/actions.js" +} + +action markTaskAsDone { + fn: import { markTaskAsDone } from "@server/actions.js" +} + +``` + + + + +```wasp title="main.wasp" +// ... + +action createTask { + fn: import { createTask } from "@server/actions.js" +} + +action markTaskAsDone { + fn: import { markTaskAsDone } from "@server/actions.js" +} +``` + +:::warning +Even though you are using TypeScript and plan to implement this Action in `src/server/actions.ts`, you still need to import it using a `.js` extension. Wasp internally uses `esnext` module resolution, which requires importing all files with a `.js` extension. This is only needed when importing `@server` files. + +Read more about ES modules in TypeScript [here](https://www.typescriptlang.org/docs/handbook/esm-node.html). If you're interested in the discussion and the reasoning behind this, read about it [in this GitHub issue](https://github.com/microsoft/TypeScript/issues/33588). +::: + + + + + + +If you want to know about all supported options for the `action` declaration, take a look at the [API Reference](#api-reference). + + + +The names of Wasp Actions and their implementations don't necessarily have to match. However, to avoid confusion, we'll keep them the same. + + + +After declaring a Wasp Action, two important things happen: + +- Wasp **generates a server-side NodeJS function** that shares its name with the Action. + +- Wasp **generates a client-side JavaScript function** that shares its name with the Action (e.g., `markTaskAsDone`). + This function takes a single optional argument - an object containing any serializable data you wish to use inside the Action. + Wasp will send this object over the network and pass it into the Action's implementation as its first positional argument (more on this when we look at the implementations). + Such an abstraction works thanks to an HTTP API route handler Wasp generates on the server, which calls the Action's NodeJS implementation under the hood. + +Generating these two functions ensures a uniform calling interface across the entire app (both client and server). + +### Implementing Actions in Node + +Now that we've declared the Action, what remains is to implement it. We've instructed Wasp to look for the Actions' implementations in the file `src/server/actions.{js,ts}`, so that's where we should export them from. + +Here's how you might implement the previously declared Actions `createTask` and `markTaskAsDone`: + + + + +```js title="src/server/actions.js" +// our "database" +let nextId = 4 +const tasks = [ + { id: 1, description: 'Buy some eggs', isDone: true }, + { id: 2, description: 'Make an omelette', isDone: false }, + { id: 3, description: 'Eat breakfast', isDone: false }, +] + +// You don't need to use the arguments if you don't need them +export const createTask = (args) => { + const newTask = { + id: nextId, + isDone: false, + description: args.description, + } + nextId += 1 + tasks.push(newTask) + return newTask +} + +// The 'args' object is something sent by the caller (most often from the client) +export const markTaskAsDone = (args) => { + const task = tasks.find((task) => task.id === args.id) + if (!task) { + // We'll show how to properly handle such errors later + return + } + task.isDone = true +} +``` + + + + + + +```ts title="src/server/actions.ts" +import { CreateTask, MarkTaskAsDone } from '@wasp/actions/types' + +type Task = { + id: number + description: string + isDone: boolean +} + +// our "database" +let nextId = 4 +const tasks = [ + { id: 1, description: 'Buy some eggs', isDone: true }, + { id: 2, description: 'Make an omelette', isDone: false }, + { id: 3, description: 'Eat breakfast', isDone: false }, +] + +// You don't need to use the arguments if you don't need them +export const createTask: CreateTask, Task> = ( + args +) => { + const newTask = { + id: nextId, + isDone: false, + description: args.description, + } + nextId += 1 + tasks.push(newTask) + return newTask +} + +// The 'args' object is something sent by the caller (most often from the client) +export const markTaskAsDone: MarkTaskAsDone, void> = ( + args +) => { + const task = tasks.find((task) => task.id === args.id) + if (!task) { + // We'll show how to properly handle such errors later + return + } + task.isDone = true +} +``` + +Wasp automatically generates the types `CreateTask` and `MarkTaskAsDone` based on the declarations in your Wasp file: + +- `CreateTask` is a generic type that Wasp automatically generated based on the Action declaration for `createTask`. +- `MarkTaskAsDone` is a generic type that Wasp automatically generated based on the Action declaration for `markTaskAsDone`. + +You can use these types to specify the Action's input and output types. + +The Action `createTask` expects to get an object of type `{ description: string }` and returns the newly created task (an object of type `Task`). + +The Action `markTaskAsDone`, expects an object of type `{ id: number }` and doesn't return anything (i.e., its return type is `void`). + +We've derived most of the payload types from the type `Task`. + +Annotating the Actions is optional, but highly recommended. Doing so enables **full-stack type safety**. We'll see what this means when calling the Action from the client. + +:::tip +Wasp uses [superjson](https://github.com/blitz-js/superjson) under the hood. In other words, you don't need to limit yourself to only sending and receiving JSON payloads. + +Send and receive any superjson-compatible payload (e.g., Dates, Sets, Lists, circular references, etc.) and let Wasp take care of the (de)serialization. + +As long as you're annotating your Actions with correct automatically generated types, TypeScript ensures your payloads are valid (i.e., that Wasp knows how to serialize and deserialize them). +::: + + + + + + +For a detailed explanation of the Action definition API (i.e., arguments and return values), check the [API Reference](#api-reference). + + + +### Using Actions + +To use an Action, you can import it from `@wasp` and call it directly. As mentioned, the usage doesn't change depending on whether you're on the server or the client: + + + + +```javascript +import createTask from '@wasp/actions/createTask.js' +import markTasAsDone from '@wasp/actions/markTasAsDone.js' + +// ... + +const newTask = await createTask({ description: 'Learn TypeScript' }) +await markTasAsDone({ id: 1 }) +``` + + + + +```typescript +import createTask from '@wasp/actions/createTask.js' +import markTasAsDone from '@wasp/actions/markTasAsDone.js' + +// TypeScript automatically infers the return values and type-checks +// the payloads. +const newTask = await createTask({ description: 'Keep learning TypeScript' }) +await markTasAsDone({ id: 1 }) +``` + + + + +When using Actions on the client, you'll most likely want to use them inside a component: + + + + +```jsx {4,25} title=src/client/pages/Task.jsx +import React from 'react' +import { useQuery } from '@wasp/queries' +import getTask from '@wasp/queries/getTask' +import markTaskAsDone from '@wasp/actions/markTaskAsDone' + +export const TaskPage = ({ id }) => { + const { data: task } = useQuery(getTask, { id }) + + if (!task) { + return

    "Loading"

    + } + + const { description, isDone } = task + return ( +
    +

    + Description: + {description} +

    +

    + Is done: + {isDone ? 'Yes' : 'No'} +

    + {isDone || ( + + )} +
    + ) +} +``` + +
    + + +```tsx {4,25} title=src/client/pages/Task.tsx +import React from 'react' +import { useQuery } from '@wasp/queries' +import getTask from '@wasp/queries/getTask' +import markTaskAsDone from '@wasp/actions/markTaskAsDone' + +export const TaskPage = ({ id }: { id: number }) => { + const { data: task } = useQuery(getTask, { id }) + + if (!task) { + return

    "Loading"

    + } + + const { description, isDone } = task + return ( +
    +

    + Description: + {description} +

    +

    + Is done: + {isDone ? 'Yes' : 'No'} +

    + {isDone || ( + + )} +
    + ) +} +``` + +
    +
    + +Since Actions don't require reactivity, they are safe to use inside components without a hook. Still, Wasp provides comes with the `useAction` hook you can use to enhance actions. Read all about it in the [API Reference](#api-reference). + +### Error Handling + +For security reasons, all exceptions thrown in the Action's NodeJS implementation are sent to the client as responses with the HTTP status code `500`, with all other details removed. +Hiding error details by default helps against accidentally leaking possibly sensitive information over the network. + +If you do want to pass additional error information to the client, you can construct and throw an appropriate `HttpError` in your implementation: + + + + +```js title=src/server/actions.js +import HttpError from '@wasp/core/HttpError.js' + +export const createTask = async (args, context) => { + throw new HttpError( + 403, // status code + "You can't do this!", // message + { foo: 'bar' } // data + ) +} +``` + + + + +```ts title=src/server/actions.ts +import { CreateTask } from '@wasp/actions/types' +import HttpError from '@wasp/core/HttpError.js' + +export const createTask: CreateTask = async (args, context) => { + throw new HttpError( + 403, // status code + "You can't do this!", // message + { foo: 'bar' } // data + ) +} +``` + + + + +### Using Entities in Actions + +In most cases, resources used in Actions will be [Entities](../../data-model/entities.md). +To use an Entity in your Action, add it to the `action` declaration in Wasp: + + + + +```wasp {4,9} title="main.wasp" + +action createTask { + fn: import { createTask } from "@server/actions.js", + entities: [Task] +} + +action markTaskAsDone { + fn: import { markTaskAsDone } from "@server/actions.js", + entities: [Task] +} +``` + + + + +```wasp {4,9} title="main.wasp" + +action createTask { + fn: import { createTask } from "@server/actions.js", + entities: [Task] +} + +action markTaskAsDone { + fn: import { markTaskAsDone } from "@server/actions.js", + entities: [Task] +} +``` + + + + +Wasp will inject the specified Entity into the Action's `context` argument, giving you access to the Entity's Prisma API. +Wasp invalidates frontend Query caches by looking at the Entities used by each Action/Query. Read more about Wasp's smart cache invalidation [here](#cache-invalidation). + + + + +```js title="src/server/actions.js" +// The 'args' object is the payload sent by the caller (most often from the client) +export const createTask = async (args, context) => { + const newTask = await context.entities.Task.create({ + data: { + description: args.description, + isDone: false, + }, + }) + return newTask +} + +export const markTaskAsDone = async (args, context) => { + await context.entities.Task.update({ + where: { id: args.id }, + data: { isDone: true }, + }) +} +``` + + + + +```ts title="src/server/actions.ts" +import { CreateTask, MarkTaskAsDone } from '@wasp/actions/types' +import { Task } from '@wasp/entities' + +// The 'args' object is the payload sent by the caller (most often from the client) +export const createTask: CreateTask, Task> = async ( + args, + context +) => { + const newTask = await context.entities.Task.create({ + data: { + description: args.description, + isDone: false, + }, + }) + return newTask +} + +export const markTaskAsDone: MarkTaskAsDone, void> = async ( + args, + context +) => { + await context.entities.Task.update({ + where: { id: args.id }, + data: { isDone: true }, + }) +} +``` + +Again, annotating the Actions is optional, but greatly improves **full-stack type safety**. + + + + +The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud). + +### Prisma Error Helpers + +In your Operations, you may wish to handle general Prisma errors with HTTP-friendly responses. + +Wasp exposes two helper functions, `isPrismaError`, and `prismaErrorToHttpError`, for this purpose. As of now, we convert two specific Prisma errors (which we will continue to expand), with the rest being `500`. See the [source here](https://github.com/wasp-lang/wasp/blob/main/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js). + +Here's how you can import and use them: + + + + +```js +import { isPrismaError, prismaErrorToHttpError } from "@wasp/utils.js"; + +// ... + +try { + await context.entities.Task.create({...}) +} catch (e) { + if (isPrismaError(e)) { + throw prismaErrorToHttpError(e) + } else { + throw e + } +} +``` + + + + +```js +import { isPrismaError, prismaErrorToHttpError } from "@wasp/utils.js"; + +// ... + +try { + await context.entities.Task.create({...}) +} catch (e) { + if (isPrismaError(e)) { + throw prismaErrorToHttpError(e) + } else { + throw e + } +} +``` + + + + +## Cache Invalidation + +One of the trickiest parts of managing a web app's state is making sure the data returned by the Queries is up to date. +Since Wasp uses _react-query_ for Query management, we must make sure to invalidate Queries (more specifically, their cached results managed by _react-query_) whenever they become stale. + +It's possible to invalidate the caches manually through several mechanisms _react-query_ provides (e.g., refetch, direct invalidation). +However, since manual cache invalidation quickly becomes complex and error-prone, Wasp offers a faster and a more effective solution to get you started: **automatic Entity-based Query cache invalidation**. +Because Actions can (and most often do) modify the state while Queries read it, Wasp invalidates a Query's cache whenever an Action that uses the same Entity is executed. + +For example, if the Action `createTask` and Query `getTasks` both use the Entity `Task`, executing `createTask` may cause the cached result of `getTasks` to become outdated. In response, Wasp will invalidate it, causing `getTasks` to refetch data from the server and update it. + +In practice, this means that Wasp keeps the Queries "fresh" without requiring you to think about cache invalidation. + +On the other hand, this kind of automatic cache invalidation can become wasteful (some updates might not be necessary) and will only work for Entities. If that's an issue, you can use the mechanisms provided by _react-query_ for now, and expect more direct support in Wasp for handling those use cases in a nice, elegant way. + +If you wish to optimistically set cache values after performing an Action, you can do so using [optimistic updates](https://stackoverflow.com/a/33009713). Configure them using Wasp's [useAction hook](#the-useaction-hook-and-optimistic-updates). This is currently the only manual cache invalidation mechanism Wasps supports natively. For everything else, you can always rely on _react-query_. + +## Differences Between Queries and Actions + +Actions and Queries are two closely related concepts in Wasp. They might seem to perform similar tasks, but Wasp treats them differently, and each concept represents a different thing. + +Here are the key differences between Queries and Actions: + +1. Actions can (and often should) modify the server's state, while Queries are only permitted to read it. Wasp relies on you adhering to this convention when performing cache invalidations, so it's crucial to follow it. +2. Actions don't need to be reactive, so you can call them directly. However, Wasp does provide a [`useAction` React hook](#the-useaction-hook-and-optimistic-updates) for adding extra behavior to the Action (like optimistic updates). +3. `action` declarations in Wasp are mostly identical to `query` declarations. The only difference lies in the declaration's name. + +## API Reference + +### Declaring Actions in Wasp + +The `action` declaration supports the following fields: + +- `fn: ServerImport` + + The import statement of the Action's NodeJs implementation. + +- `entities: [Entity]` + + A list of entities you wish to use inside your Action. + For instructions on using Entities in Actions, take a look at [the guide](#using-entities-in-actions). + +#### Example + + + + +Declaring the Action: + +```wasp +query createFoo { + fn: import { createFoo } from "@server/actions.js" + entities: [Foo] +} +``` + +Enables you to import and use it anywhere in your code (on the server or the client): + +```js +import createFoo from '@wasp/actions/createFoo' +``` + + + + +Declaring the Action: + +```wasp +query createFoo { + fn: import { createFoo } from "@server/actions.js" + entities: [Foo] +} +``` + +And also creates a type you can import on the server: + +```ts +import createFoo from '@wasp/actions' +``` + +As well as the following type import on the server: + +```ts +import type { CreateFoo } from '@wasp/actions/types' +``` + + + + +### Implementing Actions + +The Action's implementation is a NodeJS function that takes two arguments (it can be an `async` function if you need to use the `await` keyword). +Since both arguments are positional, you can name the parameters however you want, but we'll stick with `args` and `context`: + +1. `args` (type depends on the Action) + + An object containing the data **passed in when calling the Action** (e.g., filtering conditions). + Check [the usage examples](#using-actions) to see how to pass this object to the Action. + +2. `context` (type depends on the Action) + + An additional context object **passed into the Action by Wasp**. This object contains user session information, as well as information about entities. Check the [section about using entities in Actions](#using-entities-in-actions) to see how to use the entities field on the `context` object, or the [auth section](../../auth/overview#using-the-contextuser-object) to see how to use the `user` object. + + + +Afer you [declare the Action](#declaring-actions), Wasp generates a generic type you can use when defining its implementation. +For the Action declared as `createSomething`, the generated type is called `CreateSomething`: + +```ts +import { CreateSomething } from '@wasp/actions/types' +``` + +It expects two (optional) type arguments: + +1. `Input` + + The type of the `args` object (i.e., the Action's input payload). The default value is `never`. + +2. `Output` + + The type of the Action's return value (i.e., the Action's output payload). The default value is `unknown`. + +The defaults were chosen to make the type signature as permissive as possible. If don't want your Action to take/return anything, use `void` as a type argument. + + + +#### Example + + + + +The following Action: + +```wasp +action createFoo { + fn: import { createFoo } from "@server/actions.js" + entities: [Foo] +} +``` + +Expects to find a named export `createfoo` from the file `src/server/actions.js` + +```js title=actions.js +export const createFoo = (args, context) => { + // implementation +} +``` + + + + +```wasp +action createFoo { + fn: import { createFoo } from "@server/actions.js" + entities: [Foo] +} +``` + +Expects to find a named export `createfoo` from the file `src/server/actions.js` + +You can use the generated type `CreateFoo` and specify the Action's inputs and outputs using its type arguments. + +```ts title=actions.ts +import { CreateFoo } from "@wasp/actions/types"; + +type Foo = // ... + +export const createFoo: CreateFoo<{ bar: string }, Foo> = (args, context) => { + // implementation +}; +``` + +In this case, the Action expects to receive an object with a `bar` field of type `string` (this is the type of `args`), and return a value of type `Foo` (this must match the type of the Action's return value). + + + + +### The `useAction` Hook and Optimistic Updates + +Make sure you understand how [Queries](../../data-model/operations/queries.md) and [Cache Invalidation](#cache-invalidation) work before reading this chapter. + +When using Actions in components, you can enhance them with the help of the `useAction` hook. This hook comes bundled with Wasp, and is used for decorating Wasp Actions. +In other words, the hook returns a function whose API matches the original Action while also doing something extra under the hood (depending on how you configure it). + +The `useAction` hook accepts two arguments: + +- `actionFn` + + The Wasp Action (i.e., the client-side Action function generated by Wasp based on a Action declaration) you wish to enhance. + +- `actionOptions` + + An object configuring the extra features you want to add to the given Action. While this argument is technically optional, there is no point in using the `useAction` hook without providing it (it would be the same as using the Action directly). The Action options object supports the following fields: + + - `optimisticUpdates` + + An array of objects where each object defines an [optimistic update](https://stackoverflow.com/a/33009713) to perform on the Query cache. To define an optimistic update, you must specify the following properties: + + - `getQuerySpecifier` + + A function returning the Query specifier (i.e., a value used to address the Query you want to update). A Query specifier is an array specifying the query function and arguments. For example, to optimistically update the Query used with `useQuery(fetchFilteredTasks, {isDone: true }]`, your `getQuerySpecifier` function would have to return the array `[fetchFilteredTasks, { isDone: true}]`. Wasp will forward the argument you pass into the decorated Action to this function (i.e., you can use the properties of the added/changed item to address the Query). + + - `updateQuery` + + The function used to perform the optimistic update. It should return the desired state of the cache. Wasp will call it with the following arguments: + + - `item` - The argument you pass into the decorated Action. + - `oldData` - The currently cached value for the Query identified by the specifier. + +:::caution +The `updateQuery` function must be a pure function. It must return the desired cache value identified by the `getQuerySpecifier` function and _must not_ perform any side effects. + +Also, make sure you only update the Query caches affected by your Action causing the optimistic update (Wasp cannot yet verify this). + +Finally, your implementation of the `updateQuery` function should work correctly regardless of the state of `oldData` (e.g., don't rely on array positioning). If you need to do something else during your optimistic update, you can directly use _react-query_'s lower-level API (read more about it [here](#advanced-usage)). +::: + +Here's an example showing how to configure the Action `markTaskAsDone` that toggles a task's `isDone` status to perform an optimistic update: + + + + +```jsx {3,9,10,11,12,13,14,15,16,34} title=src/client/pages/Task.jsx +import React from 'react' +import { useQuery } from '@wasp/queries' +import { useAction } from '@wasp/actions' +import getTask from '@wasp/queries/getTask' +import markTaskAsDone from '@wasp/actions/markTaskAsDone' + +const TaskPage = ({ id }) => { + const { data: task } = useQuery(getTask, { id }) + const markTaskAsDoneOptimistically = useAction(markTaskAsDone, { + optimisticUpdates: [ + { + getQuerySpecifier: ({ id }) => [getTask, { id }], + updateQuery: (_payload, oldData) => ({ ...oldData, isDone: true }), + }, + ], + }) + + if (!task) { + return

    "Loading"

    + } + + const { description, isDone } = task + return ( +
    +

    + Description: + {description} +

    +

    + Is done: + {isDone ? 'Yes' : 'No'} +

    + {isDone || ( + + )} +
    + ) +} + +export default TaskPage +``` + +
    + + +```jsx {2,4,8,12,13,14,15,16,17,18,19,37} title=src/client/pages/Task.js +import React from "react"; +import { useQuery } from "@wasp/queries"; +import { useAction, OptimisticUpdateDefinition } from "@wasp/actions"; +import getTask from "@wasp/queries/getTask"; +import markTaskAsDone from "@wasp/actions/markTaskAsDone"; + +type TaskPayload = Pick; + +const TaskPage = ({ id }: { id: number }) => { + const { data: task } = useQuery(getTask, { id }); + const markTaskAsDoneOptimistically = useAction(markTaskAsDone, { + optimisticUpdates: [ + { + getQuerySpecifier: ({ id }) => [getTask, { id }], + updateQuery: (_payload, oldData) => ({ ...oldData, isDone: true }), + } as OptimisticUpdateDefinition, + ], + }); + + if (!task) { + return

    "Loading"

    ; + } + + const { description, isDone } = task; + return ( +
    +

    + Description: + {description} +

    +

    + Is done: + {isDone ? "Yes" : "No"} +

    + {isDone || ( + + )} +
    + ); +}; + +export default TaskPage; +``` + +
    +
    + +#### Advanced usage + +The `useAction` hook currently only supports specifying optimistic updates. You can expect more features in future versions of Wasp. + +Wasp's optimistic update API is deliberately small and focuses exclusively on updating Query caches (as that's the most common use case). You might need an API that offers more options or a higher level of control. If that's the case, instead of using Wasp's `useAction` hook, you can use _react-query_'s `useMutation` hook and directly work with [their low-level API](https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates). + +If you decide to use _react-query_'s API directly, you will need access to Query cache key. Wasp internally uses this key but abstracts it from the programmer. Still, you can easily obtain it by accessing the `queryCacheKey` property on any Query: + + + + +```js +import getTasks from '@wasp/queries/getTasks' + +const queryKey = getTasks.queryCacheKey +``` + + + + +```ts +import getTasks from '@wasp/queries/getTasks' + +const queryKey = getTasks.queryCacheKey +``` + + + diff --git a/web/versioned_docs/version-0.11.8/data-model/operations/overview.md b/web/versioned_docs/version-0.11.8/data-model/operations/overview.md new file mode 100644 index 0000000000..2bf1fa44d5 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/data-model/operations/overview.md @@ -0,0 +1,12 @@ +--- +title: Overview +--- + +import { Required } from '@site/src/components/Tag'; + +While Entities enable help you define your app's data model and relationships, Operations are all about working with this data. + +There are two kinds of Operations: [Queries](../../data-model/operations/queries.md) and [Actions](../../data-model/operations/actions.md). As their names suggest, +Queries are meant for reading data, and Actions are meant for changing it (either by updating existing entries or creating new ones). + +Keep reading to find out all there is to know about Operations in Wasp. diff --git a/web/versioned_docs/version-0.11.8/data-model/operations/queries.md b/web/versioned_docs/version-0.11.8/data-model/operations/queries.md new file mode 100644 index 0000000000..7987d032d2 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/data-model/operations/queries.md @@ -0,0 +1,656 @@ +--- +title: Queries +--- + +import { Required } from '@site/src/components/Tag'; +import { ShowForTs } from '@site/src/components/TsJsHelpers'; +import SuperjsonNote from './\_superjson-note.md'; + +We'll explain what Queries are and how to use them. If you're looking for a detailed API specification, skip ahead to the [API Reference](#api-reference). + +You can use Queries to fetch data from the server. They shouldn't modify the server's state. +Fetching all comments on a blog post, a list of users that liked a video, information about a single product based on its ID... All of these are perfect use cases for a Query. + +:::tip +Queries are fairly similar to Actions in terms of their API. +Therefore, if you're already familiar with Actions, you might find reading the entire guide repetitive. + +We instead recommend skipping ahead and only reading [the differences between Queries and Actions](../../data-model/operations/actions#differences-between-queries-and-actions), and consulting the [API Reference](#api-reference) as needed. +::: + +## Working with Queries + +You declare queries in the `.wasp` file and implement them using NodeJS. Wasp not only runs these queries within the server's context but also creates code that enables you to call them from any part of your codebase, whether it's on the client or server side. + +This means you don't have to build an HTTP API for your query, manage server-side request handling, or even deal with client-side response handling and caching. +Instead, just concentrate on implementing the business logic inside your query, and let Wasp handle the rest! + +To create a Query, you must: + +1. Declare the Query in Wasp using the `query` declaration. +2. Define the Query's NodeJS implementation. + +After completing these two steps, you'll be able to use the Query from any point in your code. + +### Declaring Queries + +To create a Query in Wasp, we begin with a `query` declaration. + +Let's declare two Queries - one to fetch all tasks, and another to fetch tasks based on a filter, such as whether a task is done: + + + + +```wasp title="main.wasp" +// ... + +query getAllTasks { + fn: import { getAllTasks } from "@server/queries.js" +} + +query getFilteredTasks { + fn: import { getFilteredTasks } from "@server/queries.js" +} +``` + + + + +```wasp title="main.wasp" +// ... + +query getAllTasks { + fn: import { getAllTasks } from "@server/queries.js" +} + +query getFilteredTasks { + fn: import { getFilteredTasks } from "@server/queries.js" +} +``` + +:::warning +Even though you are using TypeScript and plan to implement this Query in `src/server/queries.ts`, you still need to import it using a `.js` extension. Wasp internally uses `esnext` module resolution, which requires importing all files with a `.js` extension. This is only needed when importing `@server` files. + +Read more about ES modules in TypeScript [here](https://www.typescriptlang.org/docs/handbook/esm-node.html). If you're interested in the discussion and the reasoning behind this, read about it [in this GitHub issue](https://github.com/microsoft/TypeScript/issues/33588). +::: + + + + + + +If you want to know about all supported options for the `query` declaration, take a look at the [API Reference](#api-reference). + + + +The names of Wasp Queries and their implementations don't need to match, but we'll keep them the same to avoid confusion. + +:::info +You might have noticed that we told Wasp to import Query implementations that don't yet exist. Don't worry about that for now. We'll write the implementations imported from `queries.{js,ts}` in the next section. + +It's a good idea to start with the high-level concept (i.e., the Query declaration in the Wasp file) and only then deal with the implementation details (i.e., the Query's implementation in JavaScript). +::: + +After declaring a Wasp Query, two important things happen: + +- Wasp **generates a server-side NodeJS function** that shares its name with the Query. + +- Wasp **generates a client-side JavaScript function** that shares its name with the Query (e.g., `getFilteredTasks`). + This function takes a single optional argument - an object containing any serializable data you wish to use inside the Query. + Wasp will send this object over the network and pass it into the Query's implementation as its first positional argument (more on this when we look at the implementations). + Such an abstraction works thanks to an HTTP API route handler Wasp generates on the server, which calls the Query's NodeJS implementation under the hood. + +Generating these two functions ensures a uniform calling interface across the entire app (both client and server). + +### Implementing Queries in Node + +Now that we've declared the Query, what remains is to implement it. +We've instructed Wasp to look for the Queries' implementations in the file `src/server/queries.{js,ts}`, so that's where we should export them from. + +Here's how you might implement the previously declared Queries `getAllTasks` and `getFilteredTasks`: + + + + +```js title="src/server/queries.js" +// our "database" +const tasks = [ + { id: 1, description: 'Buy some eggs', isDone: true }, + { id: 2, description: 'Make an omelette', isDone: false }, + { id: 3, description: 'Eat breakfast', isDone: false }, +] + +// You don't need to use the arguments if you don't need them +export const getAllTasks = () => { + return tasks +} + +// The 'args' object is something sent by the caller (most often from the client) +export const getFilteredTasks = (args) => { + const { isDone } = args + return tasks.filter((task) => task.isDone === isDone) +} +``` + + + + + + +```ts title="src/server/queries.ts" +import { GetAllTasks, GetFilteredTasks } from '@wasp/queries/types' + +type Task = { + id: number + description: string + isDone: boolean +} + +// our "database" +const tasks: Task[] = [ + { id: 1, description: 'Buy some eggs', isDone: true }, + { id: 2, description: 'Make an omelette', isDone: false }, + { id: 3, description: 'Eat breakfast', isDone: false }, +] + +// You don't need to use the arguments if you don't need them +export const getAllTasks: GetAllTasks = () => { + return tasks +} + +// The 'args' object is something sent by the caller (most often from the client) +export const getFilteredTasks: GetFilteredTasks< + Pick, + Task[] +> = (args) => { + const { isDone } = args + return tasks.filter((task) => task.isDone === isDone) +} +``` + +Wasp automatically generates the types `GetTasks` and `GetFilteredTasks` based on your Wasp file's declarations: + +- `GetTasks` is a generic type automatically generated by Wasp, based on the Query declaration for `getTasks`. +- `GetFilteredTasks` is also a generic type automatically generated by Wasp, based on the Query declaration for `getFilteredTasks`. + +You can utilize these types to define the input and output types for your Query. + +For example, the Query `getTasks` doesn't expect any arguments (its input type is `void`), but it does return a list of tasks (its output type is `Task[]`). + +On the other hand, the Query `getFilteredTasks` expects an object of type `{ isDone: boolean }`. This type is derived from the `Task` type. + +While annotating the Queries is optional, it's highly recommended. Doing so enables **full-stack type safety**. We'll explore what this means when we discuss calling the Query from the client. + + + + + + + + +For a detailed explanation of the Query definition API (i.e., arguments and return values), check the [API Reference](#api-reference). + + + +### Using Queries + +To use a Query, you can import it from `@wasp` and call it directly. As mentioned, the usage doesn't change depending on whether you're on the server or the client: + + + + +```javascript +import getAllTasks from '@wasp/queries/getAllTasks.js' +import getFilteredTasks from '@wasp/queries/getFilteredTasks.js' + +// ... + +const allTasks = await getAllTasks() +const doneTasks = await getFilteredTasks({ isDone: true }) +``` + + + + +```typescript +import getAllTasks from '@wasp/queries/getAllTasks.js' +import getFilteredTasks from '@wasp/queries/getFilteredTasks.js' + +// TypeScript automatically infers the return values and type-checks +// the payloads. +const allTasks = await getAllTasks() +const doneTasks = await getFilteredTasks({ isDone: true }) +``` + + + + +#### The `useQuery` hook + +When using Queries on the client, you can make them reactive with the `useQuery` hook. +This hook comes bundled with Wasp and is a thin wrapper around the `useQuery` hook from [_react-query_](https://github.com/tannerlinsley/react-query). The only difference is that you don't need to supply the key - Wasp handles this for you automatically. + +Here's an example of calling the Queries using the `useQuery` hook: + + + + +```jsx title=src/client/MainPage.jsx +import React from 'react' +import { useQuery } from '@wasp/queries' +import getAllTasks from '@wasp/queries/getAllTasks' +import getFilteredTasks from '@wasp/queries/getFilteredTasks' + +const MainPage = () => { + const { data: allTasks, error: error1 } = useQuery(getAllTasks) + const { data: doneTasks, error: error2 } = useQuery(getFilteredTasks, { + isDone: true, + }) + + if (error1 !== null || error2 !== null) { + return
    There was an error
    + } + + return ( +
    +

    All Tasks

    + {allTasks && allTasks.length > 0 + ? allTasks.map((task) => ) + : 'No tasks'} + +

    Finished Tasks

    + {doneTasks && doneTasks.length > 0 + ? doneTasks.map((task) => ) + : 'No finished tasks'} +
    + ) +} + +const Task = ({ description, isDone }: Task) => { + return ( +
    +

    + Description: + {description} +

    +

    + Is done: + {isDone ? 'Yes' : 'No'} +

    +
    + ) +} + +export default MainPage +``` + +
    + + +```tsx title=src/client/MainPage.tsx +import React from 'react' +import { Task } from '@wasp/entities' +import { useQuery } from '@wasp/queries' +import getAllTasks from '@wasp/queries/getAllTasks' +import getFilteredTasks from '@wasp/queries/getFilteredTasks' + +const MainPage = () => { + // TypeScript will automatically infer and type-check payload types. + const { data: allTasks, error: error1 } = useQuery(getAllTasks) + const { data: doneTasks, error: error2 } = useQuery(getFilteredTasks, { + isDone: true, + }) + + if (error1 !== null || error2 !== null) { + return
    There was an error
    + } + + return ( +
    +

    All Tasks

    + {allTasks && allTasks.length > 0 + ? allTasks.map((task) => ) + : 'No tasks'} + +

    Finished Tasks

    + {doneTasks && doneTasks.length > 0 + ? doneTasks.map((task) => ) + : 'No finished tasks'} +
    + ) +} + +const Task = ({ description, isDone }: Task) => { + return ( +
    +

    + Description: + {description} +

    +

    + Is done: + {isDone ? 'Yes' : 'No'} +

    +
    + ) +} + +export default MainPage +``` + +Notice how you don't need to annotate the Query's return value type. Wasp automatically infers the from the Query's backend implementation. This is **full-stack type safety**: the types on the client always match the types on the server. + +
    +
    + + + +For a detailed specification of the `useQuery` hook, check the [API Reference](#api-reference). + + + +### Error Handling + +For security reasons, all exceptions thrown in the Query's NodeJS implementation are sent to the client as responses with the HTTP status code `500`, with all other details removed. +Hiding error details by default helps against accidentally leaking possibly sensitive information over the network. + +If you do want to pass additional error information to the client, you can construct and throw an appropriate `HttpError` in your implementation: + + + + +```js title=src/server/queries.js +import HttpError from '@wasp/core/HttpError.js' + +export const getAllTasks = async (args, context) => { + throw new HttpError( + 403, // status code + "You can't do this!", // message + { foo: 'bar' } // data + ) +} +``` + + + + +```ts title=src/server/queries.ts +import { GetAllTasks } from '@wasp/queries/types' +import HttpError from '@wasp/core/HttpError.js' + +export const getAllTasks: GetAllTasks = async (args, context) => { + throw new HttpError( + 403, // status code + "You can't do this!", // message + { foo: 'bar' } // data + ) +} +``` + + + + +If the status code is `4xx`, the client will receive a response object with the corresponding `message` and `data` fields, and it will rethrow the error (including these fields). +To prevent information leakage, the server won't forward these fields for any other HTTP status codes. + +### Using Entities in Queries + +In most cases, resources used in Queries will be [Entities](../../data-model/entities.md). +To use an Entity in your Query, add it to the `query` declaration in Wasp: + + + + +```wasp {4,9} title="main.wasp" + +query getAllTasks { + fn: import { getAllTasks } from "@server/queries.js", + entities: [Task] +} + +query getFilteredTasks { + fn: import { getFilteredTasks } from "@server/queries.js", + entities: [Task] +} +``` + + + + +```wasp {4,9} title="main.wasp" + +query getAllTasks { + fn: import { getAllTasks } from "@server/queries.js", + entities: [Task] +} + +query getFilteredTasks { + fn: import { getFilteredTasks } from "@server/queries.js", + entities: [Task] +} +``` + + + + +Wasp will inject the specified Entity into the Query's `context` argument, giving you access to the Entity's Prisma API: + + + + +```js title="src/server/queries.js" +export const getAllTasks = async (args, context) => { + return context.entities.Task.findMany({}) +} + +export const getFilteredTasks = async (args, context) => { + return context.entities.Task.findMany({ + where: { isDone: args.isDone }, + }) +} +``` + + + + +```ts title="src/server/queries.ts" +import { Task } from '@wasp/entities' +import { GetAllTasks, GetFilteredTasks } from '@wasp/queries/types' + +export const getAllTasks: GetAllTasks = async (args, context) => { + return context.entities.Task.findMany({}) +} + +export const getFilteredTasks: GetFilteredTasks< + Pick, + Task[] +> = async (args, context) => { + return context.entities.Task.findMany({ + where: { isDone: args.isDone }, + }) +} +``` + +Again, annotating the Queries is optional, but greatly improves **full-stack type safety**. + + + + +The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud). + +## API Reference + +### Declaring Queries + +The `query` declaration supports the following fields: + +- `fn: ServerImport` + + The import statement of the Query's NodeJs implementation. + +- `entities: [Entity]` + + A list of entities you wish to use inside your Query. + For instructions on using Entities in Queries, take a look at [the guide](#using-entities-in-queries). + +#### Example + + + + +Declaring the Query: + +```wasp +query getFoo { + fn: import { getFoo } from "@server/queries.js" + entities: [Foo] +} +``` + +Enables you to import and use it anywhere in your code (on the server or the client): + +```js +import getFoo from '@wasp/queries/getFoo' +``` + + + + +Declaring the Query: + +```wasp +query getFoo { + fn: import { getFoo } from "@server/queries.js" + entities: [Foo] +} +``` + +Enables you to import and use it anywhere in your code (on the server or the client): + +```ts +import getFoo from '@wasp/queries' +``` + +And also creates a type you can import on the server: + +```ts +import type { GetFoo } from '@wasp/queries/types' +``` + + + + +### Implementing Queries + +The Query's implementation is a NodeJS function that takes two arguments (it can be an `async` function if you need to use the `await` keyword). +Since both arguments are positional, you can name the parameters however you want, but we'll stick with `args` and `context`: + +1. `args` (type depends on the Query) + + An object containing the data **passed in when calling the query** (e.g., filtering conditions). + Check [the usage examples](#using-queries) to see how to pass this object to the Query. + +2. `context` (type depends on the Query) + + An additional context object **passed into the Query by Wasp**. This object contains user session information, as well as information about entities. Check the [section about using entities in Queries](#using-entities-in-queries) to see how to use the entities field on the `context` object, or the [auth section](../../auth/overview#using-the-contextuser-object) to see how to use the `user` object. + + + +Afer you [declare the query](#declaring-queries), Wasp generates a generic type you can use when defining its implementation. +For the Query declared as `getSomething`, the generated type is called `GetSomething`: + +```ts +import { GetSomething } from '@wasp/queries/types' +``` + +It expects two (optional) type arguments: + +1. `Input` + + The type of the `args` object (i.e., the Query's input payload). The default value is `never`. + +2. `Output` + + The type of the Query's return value (i.e., the Query's output payload). The default value is `unknown`. + +The defaults were chosen to make the type signature as permissive as possible. If don't want your Query to take/return anything, use `void` as a type argument. + + + +#### Example + + + + +The following Query: + +```wasp +query getFoo { + fn: import { getFoo } from "@server/queries.js" + entities: [Foo] +} +``` + +Expects to find a named export `getFoo` from the file `src/server/queries.js` + +```js title=queries.js +export const getFoo = (args, context) => { + // implementation +} +``` + + + + +The following Query: + +```wasp +query getFoo { + fn: import { getFoo } from "@server/queries.js" + entities: [Foo] +} +``` + +Expects to find a named export `getFoo` from the file `src/server/queries.js` + +You can use the generated type `GetFoo` and specify the Query's inputs and outputs using its type arguments. + +```ts title=queries.ts +import { GetFoo } from "@wasp/queries/types"; + +type Foo = // ... + +export const getFoo: GetFoo<{ id: number }, Foo> = (args, context) => { + // implementation +}; +``` + +In this case, the Query expects to receive an object with an `id` field of type `number` (this is the type of `args`), and return a value of type `Foo` (this must match the type of the Query's return value). + + + + +### The `useQuery` Hook + +Wasp's `useQuery` hook is a thin wrapper around the `useQuery` hook from [_react-query_](https://github.com/tannerlinsley/react-query). +One key difference is that Wasp doesn't expect you to supply the cache key - it takes care of it under the hood. + +Wasp's `useQuery` hook accepts three arguments: + +- `queryFn` + + The client-side query function generated by Wasp based on a `query` declaration in your `.wasp` file. + +- `queryFnArgs` + + The arguments object (payload) you wish to pass into the Query. The Query's NodeJS implementation will receive this object as its first positional argument. + +- `options` + + A _react-query_ `options` object. Use this to change + [the default + behavior](https://react-query.tanstack.com/guides/important-defaults) for + this particular Query. If you want to change the global defaults, you can do + so in the [client setup function](../../project/client-config.md#overriding-default-behaviour-for-queries). + +For an example of usage, check [this section](#the-usequery-hook). diff --git a/web/versioned_docs/version-0.11.8/general/cli.md b/web/versioned_docs/version-0.11.8/general/cli.md new file mode 100644 index 0000000000..46414825fc --- /dev/null +++ b/web/versioned_docs/version-0.11.8/general/cli.md @@ -0,0 +1,161 @@ +--- +title: CLI Reference +--- +This guide provides an overview of the Wasp CLI commands, arguments, and options. + +## Overview + +Once [installed](../quick-start), you can use the wasp command from your command line. + +If you run the `wasp` command without any arguments, it will show you a list of available commands and their descriptions: + +``` +USAGE + wasp [command-args] + +COMMANDS + GENERAL + new [] [args] Creates a new Wasp project. Run it without arguments for interactive mode. + OPTIONS: + -t|--template + Check out the templates list here: https://github.com/wasp-lang/starters + + version Prints current version of CLI. + waspls Run Wasp Language Server. Add --help to get more info. + completion Prints help on bash completion. + uninstall Removes Wasp from your system. + IN PROJECT + start Runs Wasp app in development mode, watching for file changes. + start db Starts managed development database for you. + db [args] Executes a database command. Run 'wasp db' for more info. + clean Deletes all generated code and other cached artifacts. + Wasp equivalent of 'have you tried closing and opening it again?'. + build Generates full web app code, ready for deployment. Use when deploying or ejecting. + deploy Deploys your Wasp app to cloud hosting providers. + telemetry Prints telemetry status. + deps Prints the dependencies that Wasp uses in your project. + dockerfile Prints the contents of the Wasp generated Dockerfile. + info Prints basic information about current Wasp project. + test Executes tests in your project. + +EXAMPLES + wasp new MyApp + wasp start + wasp db migrate-dev + +Docs: https://wasp-lang.dev/docs +Discord (chat): https://discord.gg/rzdnErX +Newsletter: https://wasp-lang.dev/#signup +``` + +## Commands + +### Creating a New Project + - Use `wasp new` to start the interactive mode for setting up a new Wasp project. + + This will prompt you to input the project name and to select a template. The chosen template will then be used to generate the project directory with the specified name. + + ``` + $ wasp new + Enter the project name (e.g. my-project) ▸ MyFirstProject + Choose a starter template + [1] basic (default) + [2] saas + [3] todo-ts + ▸ 1 + + 🐝 --- Creating your project from the basic template... --------------------------- + + Created new Wasp app in ./MyFirstProject directory! + To run it, do: + + cd MyFirstProject + wasp start + ``` + - To skip the interactive mode and create a new Wasp project with the default template, use `wasp new `. + + ``` + $ wasp new MyFirstProject + 🐝 --- Creating your project from the basic template... --------------------------- + + Created new Wasp app in ./MyFirstProject directory! + To run it, do: + + cd MyFirstProject + wasp start + ``` +### Project Commands + - `wasp start` launches the Wasp app in development mode. It automatically opens a browser tab with your application running and watches for any changes to .wasp or files in `src/` to automatically reflect in the browser. It also shows messages from the web app, the server and the database on stdout/stderr. + - `wasp start db` starts the database for you. This can be very handy since you don't need to spin up your own database or provide its connection URL to the Wasp app. + - `wasp clean` removes all generated code and other cached artifacts. If using SQlite, it also deletes the SQlite database. Think of this as the Wasp version of the classic "turn it off and on again" solution. + + ``` + $ wasp clean + + Deleting .wasp/ directory... + Deleted .wasp/ directory. + ``` + + - `wasp build` generates the complete web app code, which is ready for [deployment](../advanced/deployment/overview). Use this command when you're deploying or ejecting. The generated code is stored in the `.wasp/build` folder. + + - `wasp deploy` makes it easy to get your app hosted on the web. + + Currently, Wasp offers support for [Fly.io](https://fly.io). If you prefer a different hosting provider, feel free to let us know on Discord or submit a PR by updating [this TypeScript app](https://github.com/wasp-lang/wasp/tree/main/waspc/packages/deploy). + + Read more about automatic deployment [here](../advanced/deployment/cli). + + - `wasp telemetry` displays the status of [telemetry](https://wasp-lang.dev/docs/telemetry). + + ``` + $ wasp telemetry + + Telemetry is currently: ENABLED + Telemetry cache directory: /home/user/.cache/wasp/telemetry/ + Last time telemetry data was sent for this project: 2021-05-27 09:21:16.79537226 UTC + Our telemetry is anonymized and very limited in its scope: check https://wasp-lang.dev/docs/telemetry for more details. + + ``` + - `wasp deps` lists the dependencies that Wasp uses in your project. + - `wasp info` provides basic details about the current Wasp project. + +### Database Commands +Wasp provides a suite of commands for managing the database. These commands all begin with `db` and primarily execute Prisma commands behind the scenes. + + - `wasp db migrate-dev` synchronizes the development database with the current state of the schema (entities). If there are any changes in the schema, it generates a new migration and applies any pending migrations to the database. + - The `--name foo` option allows you to specify a name for the migration, while the `--create-only` option lets you create an empty migration without applying it. + + - `wasp db studio` opens the GUI for inspecting your database. + + +### Bash Completion + +To set up Bash completion, run the `wasp completion` command and follow the instructions. + + +### Miscellaneous Commands + - `wasp version` displays the current version of the CLI. + + ``` + $ wasp version + + 0.11.1 + ``` + - `wasp uninstall` removes Wasp from your system. + + ``` + $ wasp uninstall + + 🐝 --- Uninstalling Wasp ... ------------------------------------------------------ + + We will remove the following directories: + {home}/.local/share/wasp-lang/ + {home}/.cache/wasp/ + + We will also remove the following files: + {home}/.local/bin/wasp + + Are you sure you want to continue? [y/N] + y + + ✅ --- Uninstalled Wasp ----------------------------------------------------------- + ``` diff --git a/web/versioned_docs/version-0.11.8/general/language.md b/web/versioned_docs/version-0.11.8/general/language.md new file mode 100644 index 0000000000..51dd5eef94 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/general/language.md @@ -0,0 +1,91 @@ +--- +title: Wasp Language (.wasp) +--- + +Wasp language (what you write in .wasp files) is a declarative, statically typed, domain-specific language (DSL). + +It is a quite simple language, closer to JSON, CSS or SQL than to e.g. Javascript or Python, since it is not a general programming language, but more of a configuration language. + +It is pretty intuitive to learn (there isn't much to learn really!) and you can probably do just fine without reading this page and learning from the rest of the docs as you go, but if you want a bit more formal definition and deeper understanding of how it works, then read on! + +## Declarations + +The central point of Wasp language are **declarations**, and Wasp code is at the end just a bunch of declarations, each of them describing a part of your web app. + +```wasp +app MyApp { + title: "My app" +} + +route RootRoute { path: "/", to: DashboardPage } + +page DashboardPage { + component: import Dashboard from "@client/Dashboard.js" +} +``` + +In the example above we described a web app via three declarations: `app MyApp { ... }`, `route RootRoute { ... }` and `page DashboardPage { ... }`. + +Syntax for writing a declaration is ` `, where: + +- `` is one of the declaration types offered by Wasp (`app`, `route`, ...) +- `` is an identifier chosen by you to name this specific declaration +- `` is the value/definition of the declaration itself, which has to match the specific declaration body type expected by the chosen declaration type. + +So, for `app` declaration above, we have: + +- declaration type `app` +- declaration name `MyApp` (we could have used any other identifier, like `foobar`, `foo_bar`, or `hi3Ho`) +- declaration body `{ title: "My app" }`, which is a dictionary with field `title` that has string value. + Type of this dictionary is in line with the declaration body type of the `app` declaration type. + If we provided something else, e.g. changed `title` to `little`, we would get a type error from Wasp compiler since that does not match the expected type of the declaration body for `app`. + +Each declaration has a meaning behind it that describes how your web app should behave and function. + +All the other types in Wasp language (primitive types (`string`, `number`), composite types (`dict`, `list`), enum types (`DbSystem`), ...) are used to define the declaration bodies. + +## Complete List of Wasp Types + +Wasp's type system can be divided into two main categories of types: **fundamental types** and **domain types**. + +While fundamental types are here to be basic building blocks of a a language, and are very similar to what you would see in other popular languages, domain types are what makes Wasp special, as they model the concepts of a web app like `page`, `route` and similar. + +- Fundamental types ([source of truth](https://github.com/wasp-lang/wasp/blob/main/waspc/src/Wasp/Analyzer/Type.hs)) + - Primitive types + - **string** (`"foo"`, `"they said: \"hi\""`) + - **bool** (`true`, `false`) + - **number** (`12`, `14.5`) + - **declaration reference** (name of existing declaration: `TaskPage`, `updateTask`) + - **ServerImport** (external server import) (`import Foo from "@server/bar.js"`, `import { Smth } from "@server/a/b.js"`) + - The path has to start with "@server". The rest is relative to the `src/server` directory. + - import has to be a default import `import Foo` or a single named import `import { Foo }`. + - **ClientImport** (external client import) (`import Foo from "@client/bar.js"`, `import { Smth } from "@client/a/b.js"`) + - The path has to start with "@client". The rest is relative to the `src/client` directory. + - import has to be a default import `import Foo` or a single named import `import { Foo }`. + - **json** (`{=json { a: 5, b: ["hi"] } json=}`) + - **psl** (Prisma Schema Language) (`{=psl psl=}`) + - Check [Prisma docs](https://www.prisma.io/docs/concepts/components/prisma-schema/data-model) for the syntax of psl data model. + - Composite types + - **dict** (dictionary) (`{ a: 5, b: "foo" }`) + - **list** (`[1, 2, 3]`) + - **tuple** (`(1, "bar")`, `(2, 4, true)`) + - Tuples can be of size 2, 3 and 4. +- Domain types ([source of truth](https://github.com/wasp-lang/wasp/blob/main/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs)) + - Declaration types + - **action** + - **api** + - **apiNamespace** + - **app** + - **entity** + - **job** + - **page** + - **query** + - **route** + - **crud** + - Enum types + - **DbSystem** + - **HttpMethod** + - **JobExecutor** + - **EmailProvider** + +You can find more details about each of the domain types, both regarding their body types and what they mean, in the corresponding doc pages covering their features. diff --git a/web/versioned_docs/version-0.11.8/introduction/editor-setup.md b/web/versioned_docs/version-0.11.8/introduction/editor-setup.md new file mode 100644 index 0000000000..f025eccd32 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/introduction/editor-setup.md @@ -0,0 +1,23 @@ +--- +title: Editor Setup +slug: /editor-setup +--- + +:::note +This page assumes you have already installed Wasp. If you do not have Wasp installed yet, check out the [Quick Start](./quick-start.md) guide. +::: + +Wasp comes with the Wasp language server, which gives supported editors powerful support and integration with the language. + +## VSCode + +Currently, Wasp only supports integration with VSCode. Install the [Wasp language extension](https://marketplace.visualstudio.com/items?itemName=wasp-lang.wasp) to get syntax highlighting and integration with the Wasp language server. + +The extension enables: +- syntax highlighting for `.wasp` files +- scaffolding of new project files +- code completion +- diagnostics (errors and warnings) +- go to definition + +and more! diff --git a/web/docs/introduction/what-is-wasp.md b/web/versioned_docs/version-0.11.8/introduction/introduction.md similarity index 97% rename from web/docs/introduction/what-is-wasp.md rename to web/versioned_docs/version-0.11.8/introduction/introduction.md index 542dcaf274..fa05b70824 100644 --- a/web/docs/introduction/what-is-wasp.md +++ b/web/versioned_docs/version-0.11.8/introduction/introduction.md @@ -3,10 +3,10 @@ title: Introduction slug: / --- -import ImgWithCaption from '../../blog/components/ImgWithCaption' +import ImgWithCaption from '@site/blog/components/ImgWithCaption' :::note -If you are looking for the installation instructions, check out the [Quick Start](/docs/quick-start) section. +If you are looking for the installation instructions, check out the [Quick Start](./quick-start.md) section. ::: We will give a brief overview of what Wasp is, how it works on a high level and when to use it. @@ -42,13 +42,13 @@ Define your app in the Wasp config and get: - async processing jobs, - React Query powered data fetching, - security best practices, -- and more. +- and more. You don't need to write any code for these features, Wasp will take care of it for you 🤯 And what's even better, Wasp also maintains the code for you, so you don't have to worry about keeping up with the latest security best practices. As Wasp updates, so does your app. ## So what does the code look like? -Let's say you want to build a web app that allows users to **create and share their favorite recipes**. +Let's say you want to build a web app that allows users to **create and share their favorite recipes**. Let's start with the main.wasp file: it is the central file of your app, where you describe the app from the high level. @@ -170,7 +170,7 @@ export function HomePage({ user }: { user: User }) { And voila! We are listing all the recipes in our app 🎉 -This was just a quick example to give you a taste of what Wasp is. For step by step tour through the most important Wasp features, check out the [Todo app tutorial](/docs/tutorial/create). +This was just a quick example to give you a taste of what Wasp is. For step by step tour through the most important Wasp features, check out the [Todo app tutorial](../tutorial/01-create.md). :::note Above we skipped defining /login and /signup pages to keep the example a bit shorter, but those are very simple to do by using Wasp's Auth UI feature. diff --git a/web/versioned_docs/version-0.11.8/introduction/quick-start.md b/web/versioned_docs/version-0.11.8/introduction/quick-start.md new file mode 100644 index 0000000000..6d382227a6 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/introduction/quick-start.md @@ -0,0 +1,143 @@ +--- +title: Quick Start +slug: /quick-start +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +## Installation + +:::tip Try Wasp Without Installing 🤔? + Give Wasp a spin in the browser without any setup by running our [Wasp Template for Gitpod](https://github.com/wasp-lang/gitpod-template) +::: + + +Welcome, new Waspeteer 🐝! + +To install Wasp on Linux / OSX / WSL(Win), open your terminal and run: + +```shell +curl -sSL https://get.wasp-lang.dev/installer.sh | sh +``` + + ℹ️ Wasp requires Node.js and will warn you if it is missing: check below for [more details](#requirements). + +Then, create a new app by running: + +```shell +wasp new +``` +and then run the app: + +```shell +cd +wasp start +``` + +That's it 🎉 You have successfully created and served a new web app at and Wasp is serving both frontend and backend for you. + +:::note Something Unclear? +Check [More Details](#more-details) section below if anything went wrong, or if you have additional questions. +::: + + +### What next? + + - [ ] 👉 **Check out the [Todo App tutorial](../tutorial/01-create.md), which will take you through all the core features of Wasp!** 👈 + - [ ] [Setup your editor](./editor-setup.md) for working with Wasp. + - [ ] Join us on [Discord](https://discord.gg/rzdnErX)! Any feedback or questions you have, we are there for you. + - [ ] Follow Wasp development by subscribing to our newsletter: https://wasp-lang.dev/#signup . We usually send 1 per month, and Matija does his best to unleash his creativity to make them engaging and fun to read :D! + +------ + +## More details + +### Requirements + +You must have Node.js (and NPM) installed on your machine and available in `PATH`. We rely on the latest Node.js LTS version (currently `v18.14.2`). + +We recommend using [nvm](https://github.com/nvm-sh/nvm) for managing your Node.js installation version(s). + +
    + + Quick guide on installing/using nvm + +
    + + Install nvm via your OS package manager (`apt`, `pacman`, `homebrew`, ...) or via the [nvm](https://github.com/nvm-sh/nvm#install--update-script) install script. + + Then, install a version of Node.js that you need: + ```shell + nvm install 18 + ``` + + Finally, whenever you need to ensure a specific version of Node.js is used, run: + ```shell + nvm use 18 + ``` + to set the Node.js version for the current shell session. + + You can run + ```shell + node -v + ``` + to check the version of Node.js currently being used in this shell session. + + Check NVM repo for more details: https://github.com/nvm-sh/nvm. + +
    +
    + + +### Installation + + + + +Open your terminal and run: + +```shell +curl -sSL https://get.wasp-lang.dev/installer.sh | sh +``` + +:::note Running Wasp on Mac with Mx chip (arm64) +**Experiencing the 'Bad CPU type in executable' issue on a device with arm64 (Apple Silicon)?** +Given that the wasp binary is built for x86 and not for arm64 (Apple Silicon), you'll need to install [Rosetta on your Mac](https://support.apple.com/en-us/HT211861) if you are using a Mac with Mx (M1, M2, ...). Rosetta is a translation process that enables users to run applications designed for x86 on arm64 (Apple Silicon). To install Rosetta, run the following command in your terminal +```bash +softwareupdate --install-rosetta +``` +Once Rosetta is installed, you should be able to run Wasp without any issues. +::: + + + + + +With Wasp for Windows, we are almost there: Wasp is successfully compiling and running on Windows but there is a bug or two stopping it from fully working. Check it out [here](https://github.com/wasp-lang/wasp/issues/48) if you are interested in helping. + +In the meantime, the best way to start using Wasp on Windows is by using [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). Once you set up Ubuntu on WSL, just follow Linux instructions for installing Wasp. You can refer to this [article](https://wasp-lang.dev/blog/2023/11/21/guide-windows-development-wasp-wsl) if you prefer a step by step guide to using Wasp in WSL environment. If you need further help, reach out to us on [Discord](https://discord.gg/rzdnErX) - we have some community members using WSL that might be able to help you. +:::caution + If you are using WSL2, make sure that your Wasp project is not on the Windows file system, but instead on the Linux file system. Otherwise, Wasp won't be able to detect file changes, due to the [issue in WSL2](https://github.com/microsoft/WSL/issues/4739). +::: + + + + + +If the installer is not working for you or your OS is not supported, you can try building Wasp from the source. + +To install from source, you need to clone the [wasp repo](https://github.com/wasp-lang/wasp), install [Cabal](https://cabal.readthedocs.io/en/stable/getting-started.html) on your machine and then run `cabal install` from the `waspc/` dir. + +If you have never built Wasp before, this might take some time due to `cabal` downloading dependencies for the first time. + +Check [waspc/](https://github.com/wasp-lang/wasp/tree/main/waspc) for more details on building Wasp from the source. + + + diff --git a/web/versioned_docs/version-0.11.8/project/_baseDirEnvNote.md b/web/versioned_docs/version-0.11.8/project/_baseDirEnvNote.md new file mode 100644 index 0000000000..472263cc6a --- /dev/null +++ b/web/versioned_docs/version-0.11.8/project/_baseDirEnvNote.md @@ -0,0 +1,6 @@ +:::caution Setting the correct env variable + +If you set the `baseDir` option, make sure that the `WASP_WEB_CLIENT_URL` env variable also includes that base directory. + +For example, if you are serving your app from `https://example.com/my-app`, the `WASP_WEB_CLIENT_URL` should be also set to `https://example.com/my-app`, and not just `https://example.com`. +::: \ No newline at end of file diff --git a/web/versioned_docs/version-0.11.8/project/client-config.md b/web/versioned_docs/version-0.11.8/project/client-config.md new file mode 100644 index 0000000000..9b9070ef9c --- /dev/null +++ b/web/versioned_docs/version-0.11.8/project/client-config.md @@ -0,0 +1,449 @@ +--- +title: Client Config +--- + +import BaseDirEnvNote from './_baseDirEnvNote.md' + +import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' + +You can configure the client using the `client` field inside the `app` declaration: + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.jsx", + setupFn: import mySetupFunction from "@client/myClientSetupCode.js" + } +} +``` + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.tsx", + setupFn: import mySetupFunction from "@client/myClientSetupCode.ts" + } +} +``` + + + + +## Root Component + +Wasp gives you the option to define a "wrapper" component for your React app. + +It can be used for a variety of purposes, but the most common ones are: + +- Defining a common layout for your application. +- Setting up various providers that your application needs. + +### Defining a Common Layout + +Let's define a common layout for your application: + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.jsx", + } +} +``` + +```jsx title="src/client/Root.jsx" +export default function Root({ children }) { + return ( +
    +
    +

    My App

    +
    + {children} +
    +

    My App footer

    +
    +
    + ) +} +``` + +
    + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.tsx", + } +} +``` + +```tsx title="src/client/Root.tsx" +export default function Root({ children }: { children: React.ReactNode }) { + return ( +
    +
    +

    My App

    +
    + {children} +
    +

    My App footer

    +
    +
    + ) +} +``` + +
    +
    + +### Setting up a Provider + +This is how to set up various providers that your application needs: + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.jsx", + } +} +``` + +```jsx title="src/client/Root.jsx" +import store from './store' +import { Provider } from 'react-redux' + +export default function Root({ children }) { + return {children} +} +``` + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.tsx", + } +} +``` + +```tsx title="src/client/Root.tsx" +import store from './store' +import { Provider } from 'react-redux' + +export default function Root({ children }: { children: React.ReactNode }) { + return {children} +} +``` + + + + +As long as you render the children, you can do whatever you want in your root +component. + +Read more about the root component in the [API Reference](#rootcomponent-clientimport). + +## Setup Function + +`setupFn` declares a TypescriptJavaScript function that Wasp executes on the client before everything else. + +### Running Some Code + +We can run any code we want in the setup function. + +For example, here's a setup function that logs a message every hour: + + + + +```js title="src/client/myClientSetupCode.js" +export default async function mySetupFunction() { + let count = 1 + setInterval( + () => console.log(`You have been online for ${count++} hours.`), + 1000 * 60 * 60 + ) +} +``` + + + + +```ts title="src/client/myClientSetupCode.ts" +export default async function mySetupFunction(): Promise { + let count = 1 + setInterval( + () => console.log(`You have been online for ${count++} hours.`), + 1000 * 60 * 60 + ) +} +``` + + + + +### Overriding Default Behaviour for Queries + +:::info +You can change the options for a **single** Query using the `options` object, as described [here](../data-model/operations/queries#the-usequery-hook-1). +::: + +Wasp's `useQuery` hook uses `react-query`'s `useQuery` hook under the hood. Since `react-query` comes configured with aggressive but sane default options, you most likely won't have to change those defaults for all Queries. + +If you do need to change the global defaults, you can do so inside the client setup function. + +Wasp exposes a `configureQueryClient` hook that lets you configure _react-query_'s `QueryClient` object: + + + + +```js title="src/client/myClientSetupCode.js" +import { configureQueryClient } from '@wasp/queryClient' + +export default async function mySetupFunction() { + // ... some setup + configureQueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + }, + }, + }) + // ... some more setup +} +``` + + + + +```ts title="src/client/myClientSetupCode.ts" +import { configureQueryClient } from '@wasp/queryClient' + +export default async function mySetupFunction(): Promise { + // ... some setup + configureQueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + }, + }, + }) + // ... some more setup +} +``` + + + + +Make sure to pass in an object expected by the `QueryClient`'s constructor, as +explained in +[react-query's docs](https://tanstack.com/query/v4/docs/react/reference/QueryClient). + +Read more about the setup function in the [API Reference](#setupfn-clientimport). + +## Base Directory + +If you need to serve the client from a subdirectory, you can use the `baseDir` option: + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + baseDir: "/my-app", + } +} +``` + +This means that if you serve your app from `https://example.com/my-app`, the +router will work correctly, and all the assets will be served from +`https://example.com/my-app`. + + + +## API Reference + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.jsx", + setupFn: import mySetupFunction from "@client/myClientSetupCode.js" + } +} +``` + + + + +```wasp title="main.wasp" +app MyApp { + title: "My app", + // ... + client: { + rootComponent: import Root from "@client/Root.tsx", + setupFn: import mySetupFunction from "@client/myClientSetupCode.ts", + baseDir: "/my-app", + } +} +``` + + + + +Client has the following options: + +- #### `rootComponent: ClientImport` + + `rootComponent` defines the root component of your client application. It is + expected to be a React component, and Wasp will use it to wrap your entire app. + It must render its children, which are the actual pages of your application. + + Here's an example of a root component that both sets up a provider and + renders a custom layout: + + + + + ```jsx title="src/client/Root.jsx" + import store from './store' + import { Provider } from 'react-redux' + + export default function Root({ children }) { + return ( + + {children} + + ) + } + + function Layout({ children }) { + return ( +
    +
    +

    My App

    +
    + {children} +
    +

    My App footer

    +
    +
    + ) + } + ``` + +
    + + + ```tsx title="src/client/Root.tsx" + import store from './store' + import { Provider } from 'react-redux' + + export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) + } + + function Layout({ children }: { children: React.ReactNode }) { + return ( +
    +
    +

    My App

    +
    + {children} +
    +

    My App footer

    +
    +
    + ) + } + ``` + +
    +
    + +- #### `setupFn: ClientImport` + + + + `setupFn` declares a Typescript function that Wasp executes on the client + before everything else. It is expected to be asynchronous, and + Wasp will await its completion before rendering the page. The function takes no + arguments, and its return value is ignored. + + + + + `setupFn` declares a JavaScript function that Wasp executes on the client + before everything else. It is expected to be asynchronous, and + Wasp will await its completion before rendering the page. The function takes no + arguments, and its return value is ignored. + + + You can use this function to perform any custom setup (e.g., setting up + client-side periodic jobs). + + + + + ```js title="src/client/myClientSetupCode.js" + export default async function mySetupFunction() { + // Run some code + } + ``` + + + + + ```ts title="src/client/myClientSetupCode.ts" + export default async function mySetupFunction(): Promise { + // Run some code + } + ``` + + + + +- #### `baseDir: String` + + If you need to serve the client from a subdirectory, you can use the `baseDir` option. + + If you set `baseDir` to `/my-app` for example, that will make Wasp set the `basename` prop of the `Router` to + `/my-app`. It will also set the `base` option of the Vite config to `/my-app`. + + This means that if you serve your app from `https://example.com/my-app`, the router will work correctly, and all the assets will be served from `https://example.com/my-app`. + + diff --git a/web/versioned_docs/version-0.11.8/project/css-frameworks.md b/web/versioned_docs/version-0.11.8/project/css-frameworks.md new file mode 100644 index 0000000000..9f30585e1a --- /dev/null +++ b/web/versioned_docs/version-0.11.8/project/css-frameworks.md @@ -0,0 +1,111 @@ +--- +title: CSS Frameworks +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +## Tailwind + +To enable support for Tailwind in your project, you need to add two config files — [`tailwind.config.cjs`](https://tailwindcss.com/docs/configuration#configuration-options) and `postcss.config.cjs` — to the root directory. + +With these files present, Wasp installs the necessary dependencies and copies your configuration to the generated project. You can then use [Tailwind CSS directives](https://tailwindcss.com/docs/functions-and-directives#directives) in your CSS and Tailwind classes on your React components. + +```bash title="tree ." {13-14} +. +├── main.wasp +├── src +│   ├── client +│   │   ├── tsconfig.json +│   │   ├── Main.css +│   │   ├── MainPage.js +│   │   └── waspLogo.png +│   ├── server +│   │   └── tsconfig.json +│   └── shared +│   └── tsconfig.json +├── postcss.config.cjs +└── tailwind.config.cjs +``` + +:::tip Tailwind not working? +If you can not use Tailwind after adding the required config files, make sure to restart `wasp start`. This is sometimes needed to ensure that Wasp picks up the changes and enables Tailwind integration. +::: + +### Enabling Tailwind Step-by-Step + +:::caution +Make sure to use the `.cjs` extension for these config files, if you name them with a `.js` extension, Wasp will not detect them. +::: + +1. Add `./tailwind.config.cjs`. + + ```js title="./tailwind.config.cjs" + /** @type {import('tailwindcss').Config} */ + module.exports = { + content: [ "./src/**/*.{js,jsx,ts,tsx}" ], + theme: { + extend: {}, + }, + plugins: [], + } + ``` + +2. Add `./postcss.config.cjs`. + + ```js title="./postcss.config.cjs" + module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + } + ``` + +3. Import Tailwind into your CSS file. For example, in a new project you might import Tailwind into `Main.css`. + + ```css title="./src/client/Main.css" {1-3} + @tailwind base; + @tailwind components; + @tailwind utilities; + + /* ... */ + ``` + +4. Start using Tailwind 🥳 + + ```jsx title="./src/client/MainPage.jsx" + // ... + +

    + Hello world! +

    + + // ... + ``` + +### Adding Tailwind Plugins + +To add Tailwind plugins, add it to [dependencies](../project/dependencies) in your `main.wasp` file and to the plugins list in your `tailwind.config.cjs` file: + +```wasp title="./main.wasp" {4-5} +app todoApp { + // ... + dependencies: [ + ("@tailwindcss/forms", "^0.5.3"), + ("@tailwindcss/typography", "^0.5.7"), + ], + // ... +} +``` + +```js title="./tailwind.config.cjs" {5-6} +/** @type {import('tailwindcss').Config} */ +module.exports = { + // ... + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + ], + // ... +} +``` diff --git a/web/versioned_docs/version-0.11.8/project/custom-vite-config.md b/web/versioned_docs/version-0.11.8/project/custom-vite-config.md new file mode 100644 index 0000000000..eba60b86c3 --- /dev/null +++ b/web/versioned_docs/version-0.11.8/project/custom-vite-config.md @@ -0,0 +1,122 @@ +--- +title: Custom Vite Config +--- + +import { ShowForTs, ShowForJs } from '@site/src/components/TsJsHelpers' + +Wasp uses [Vite](https://vitejs.dev/) for serving the client during development and bundling it for production. If you want to customize the Vite config, you can do that by editing the `vite.config.ts` file in your `src/client` directory. + +Wasp will use your config and **merge** it with the default Wasp's Vite config. + +Vite config customization can be useful for things like: + +- Adding custom Vite plugins. +- Customising the dev server. +- Customising the build process. + +Be careful with making changes to the Vite config, as it can break the Wasp's client build process. Check out the default Vite config [here](https://github.com/wasp-lang/wasp/blob/main/waspc/data/Generator/templates/react-app/vite.config.ts) to see what you can change. + +## Examples + +Below are some examples of how you can customize the Vite config. + +### Changing the Dev Server Behaviour + +If you want to stop Vite from opening the browser automatically when you run `wasp start`, you can do that by customizing the `open` option. + + + + +```js title="src/client/vite.config.js" +export default { + server: { + open: false, + }, +} +``` + + + + +```ts title="src/client/vite.config.ts" +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + open: false, + }, +}) +``` + + + + +### Custom Dev Server Port + +You have access to all of the [Vite dev server options](https://vitejs.dev/config/server-options.html) in your custom Vite config. You can change the dev server port by setting the `port` option. + + + + +```js title="src/client/vite.config.js" +export default { + server: { + port: 4000, + }, +} +``` + +```env title=".env.server" +WASP_WEB_CLIENT_URL=http://localhost:4000 +``` + + + + +```ts title="src/client/vite.config.ts" +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 4000, + }, +}) +``` + +```env title=".env.server" +WASP_WEB_CLIENT_URL=http://localhost:4000 +``` + + + + +:::warning Changing the dev server port +⚠️ Be careful when changing the dev server port, you'll need to update the `WASP_WEB_CLIENT_URL` env var in your `.env.server` file. +::: + +### Customising the Base Path + +If you, for example, want to serve the client from a different path than `/`, you can do that by customizing the `base` option. + + + + +```js title="src/client/vite.config.js" +export default { + base: '/my-app/', +} +``` + + + + +```ts title="src/client/vite.config.ts" +import { defineConfig } from 'vite' + +export default defineConfig({ + base: '/my-app/', +}) +``` + + + diff --git a/web/versioned_docs/version-0.11.8/project/customizing-app.md b/web/versioned_docs/version-0.11.8/project/customizing-app.md new file mode 100644 index 0000000000..579feb2e3f --- /dev/null +++ b/web/versioned_docs/version-0.11.8/project/customizing-app.md @@ -0,0 +1,140 @@ +--- +title: Customizing the App +--- + +import { Required } from '@site/src/components/Tag'; + +Each Wasp project can have only one `app` type declaration. It is used to configure your app and its components. + +```wasp +app todoApp { + wasp: { + version: "^0.11.1" + }, + title: "ToDo App", + head: [ + "" + ] +} +``` + +We'll go through some common customizations you might want to do to your app. For more details on each of the fields, check out the [API Reference](#api-reference). + +### Changing the App Title + +You may want to change the title of your app, which appears in the browser tab, next to the favicon. You can change it by changing the `title` field of your `app` declaration: + +```wasp +app myApp { + wasp: { + version: "^0.11.1" + }, + title: "BookFace" +} +``` + +### Adding Additional Lines to the Head + +If you are looking to add additional style sheets or scripts to your app, you can do so by adding them to the `head` field of your `app` declaration. + +An example of adding extra style sheets and scripts: + +```wasp +app myApp { + wasp: { + version: "^0.11.1" + }, + title: "My App", + head: [ // optional + "", + "", + "" + ] +} +``` + +## API Reference + +```wasp +app todoApp { + wasp: { + version: "^0.11.1" + }, + title: "ToDo App", + head: [ + "" + ], + auth: { + // ... + }, + client: { + // ... + }, + server: { + // ... + }, + db: { + // ... + }, + dependencies: [ + // ... + ], + emailSender: { + // ... + }, + webSocket: { + // ... + } +} +``` + +The `app` declaration has the following fields: + +- `wasp: dict` + Wasp compiler configuration. It is a dictionary with a single field: + + - `version: string` + + The version specifies which versions of Wasp are compatible with the app. It should contain a valid [SemVer range](https://github.com/npm/node-semver#ranges) + + :::info + For now, the version field only supports caret ranges (i.e., `^x.y.z`). Support for the full specification will come in a future version of Wasp + ::: + +- `title: string` + + Title of your app. It will appear in the browser tab, next to the favicon. + +- `head: [string]` + + List of additional lines (e.g. `` or `