From 87a24b18de91cbaea6f37441e8f69fb9d46c28ed Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 15 Nov 2023 13:06:13 +0100 Subject: [PATCH 01/62] WIP: Auth entity Signed-off-by: Mihovil Ilakovac --- .../examples/auth-model-experiment/.gitignore | 11 +++ .../examples/auth-model-experiment/.wasproot | 1 + .../examples/auth-model-experiment/main.wasp | 27 ++++++ .../20231114165906_initial/migration.sql | 20 ++++ .../migrations/migration_lock.toml | 3 + .../auth-model-experiment/src/.waspignore | 3 + .../auth-model-experiment/src/client/Main.css | 89 ++++++++++++++++++ .../src/client/MainPage.jsx | 40 ++++++++ .../auth-model-experiment/src/client/auth.jsx | 5 + .../src/client/tsconfig.json | 55 +++++++++++ .../src/client/vite-env.d.ts | 1 + .../src/client/vite.config.ts | 7 ++ .../src/client/waspLogo.png | Bin 0 -> 24877 bytes .../src/server/tsconfig.json | 48 ++++++++++ .../src/shared/tsconfig.json | 28 ++++++ .../Analyzer/StdTypeDefinitions/Entity.hs | 11 ++- waspc/src/Wasp/AppSpec/Valid.hs | 31 +----- waspc/src/Wasp/Generator/DbGenerator.hs | 56 ++++++++++- waspc/waspc.cabal | 1 + 19 files changed, 403 insertions(+), 34 deletions(-) create mode 100644 waspc/examples/auth-model-experiment/.gitignore create mode 100644 waspc/examples/auth-model-experiment/.wasproot create mode 100644 waspc/examples/auth-model-experiment/main.wasp create mode 100644 waspc/examples/auth-model-experiment/migrations/20231114165906_initial/migration.sql create mode 100644 waspc/examples/auth-model-experiment/migrations/migration_lock.toml create mode 100644 waspc/examples/auth-model-experiment/src/.waspignore create mode 100644 waspc/examples/auth-model-experiment/src/client/Main.css create mode 100644 waspc/examples/auth-model-experiment/src/client/MainPage.jsx create mode 100644 waspc/examples/auth-model-experiment/src/client/auth.jsx create mode 100644 waspc/examples/auth-model-experiment/src/client/tsconfig.json create mode 100644 waspc/examples/auth-model-experiment/src/client/vite-env.d.ts create mode 100644 waspc/examples/auth-model-experiment/src/client/vite.config.ts create mode 100644 waspc/examples/auth-model-experiment/src/client/waspLogo.png create mode 100644 waspc/examples/auth-model-experiment/src/server/tsconfig.json create mode 100644 waspc/examples/auth-model-experiment/src/shared/tsconfig.json diff --git a/waspc/examples/auth-model-experiment/.gitignore b/waspc/examples/auth-model-experiment/.gitignore new file mode 100644 index 0000000000..ad2da72f42 --- /dev/null +++ b/waspc/examples/auth-model-experiment/.gitignore @@ -0,0 +1,11 @@ +/.wasp/ + +# We ignore env files recognized and used by Wasp. +.env.server +.env.client + +# To be extra safe, we by default ignore any files with `.env` extension in them. +# If this is too agressive for you, consider allowing specific files with `!` operator, +# or modify/delete these two lines. +*.env +*.env.* diff --git a/waspc/examples/auth-model-experiment/.wasproot b/waspc/examples/auth-model-experiment/.wasproot new file mode 100644 index 0000000000..ca2cfdb482 --- /dev/null +++ b/waspc/examples/auth-model-experiment/.wasproot @@ -0,0 +1 @@ +File marking the root of Wasp project. diff --git a/waspc/examples/auth-model-experiment/main.wasp b/waspc/examples/auth-model-experiment/main.wasp new file mode 100644 index 0000000000..50b846aead --- /dev/null +++ b/waspc/examples/auth-model-experiment/main.wasp @@ -0,0 +1,27 @@ +app authModelExperiment { + wasp: { + version: "^0.11.8" + }, + title: "auth-model-experiment", + auth: { + userEntity: User, + methods: { + usernameAndPassword: {} + }, + onAuthFailedRedirectTo: "/" + } +} + +route RootRoute { path: "/", to: MainPage } +page MainPage { + component: import Main from "@client/MainPage.jsx" +} + +entity User {=psl + id String @id @default(uuid()) +psl=} + +route SignupRoute { path: "/signup", to: Signup } +page Signup { + component: import { Signup } from "@client/auth.jsx" +} \ No newline at end of file diff --git a/waspc/examples/auth-model-experiment/migrations/20231114165906_initial/migration.sql b/waspc/examples/auth-model-experiment/migrations/20231114165906_initial/migration.sql new file mode 100644 index 0000000000..350a379ca7 --- /dev/null +++ b/waspc/examples/auth-model-experiment/migrations/20231114165906_initial/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "authId" TEXT +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "userId" TEXT, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_username_key" ON "Auth"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/waspc/examples/auth-model-experiment/migrations/migration_lock.toml b/waspc/examples/auth-model-experiment/migrations/migration_lock.toml new file mode 100644 index 0000000000..e5e5c4705a --- /dev/null +++ b/waspc/examples/auth-model-experiment/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/waspc/examples/auth-model-experiment/src/.waspignore b/waspc/examples/auth-model-experiment/src/.waspignore new file mode 100644 index 0000000000..1c432f30d9 --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/.waspignore @@ -0,0 +1,3 @@ +# Ignore editor tmp files +**/*~ +**/#*# diff --git a/waspc/examples/auth-model-experiment/src/client/Main.css b/waspc/examples/auth-model-experiment/src/client/Main.css new file mode 100644 index 0000000000..b6e7ed3f13 --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/Main.css @@ -0,0 +1,89 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; +} + +.container { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +main { + padding: 5rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +main p { + font-size: 1.2rem; +} + +.logo { + margin-bottom: 2rem; +} + +.logo img { + max-height: 200px; +} + +.welcome-title { + font-weight: 500; +} + +.welcome-subtitle { + font-weight: 400; + margin-bottom: 3rem; +} + +.buttons { + display: flex; + flex-direction: row; +} + +.buttons .button:not(:last-child) { + margin-right: 0.5rem; +} + +.button { + border-radius: 3px; + font-size: 1.2rem; + padding: 1rem 2rem; + text-align: center; + font-weight: 700; + text-decoration: none; +} + +.button-filled { + border: 2px solid #bf9900; + background-color: #bf9900; + color: #f4f4f4; +} + +.button-outline { + border: 2px solid #8a9cff; + color: #8a9cff; + background-color: none; +} + +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; +} diff --git a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx new file mode 100644 index 0000000000..c53ad8abaa --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx @@ -0,0 +1,40 @@ +import waspLogo from './waspLogo.png' +import './Main.css' + +const MainPage = () => { + return ( +
+
+
+ wasp +
+ +

Welcome to Wasp - you just started a new app!

+

+ This is page MainPage located at route /. + Open src/client/MainPage.jsx to edit it. +

+ +
+ + Take the Tutorial + + + Chat on Discord + +
+
+
+ ) +} +export default MainPage diff --git a/waspc/examples/auth-model-experiment/src/client/auth.jsx b/waspc/examples/auth-model-experiment/src/client/auth.jsx new file mode 100644 index 0000000000..9d9869f5bb --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/auth.jsx @@ -0,0 +1,5 @@ +import { SignupForm } from "@wasp/auth/forms/Signup"; + +export function Signup() { + return ; +} diff --git a/waspc/examples/auth-model-experiment/src/client/tsconfig.json b/waspc/examples/auth-model-experiment/src/client/tsconfig.json new file mode 100644 index 0000000000..d501a4193a --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/tsconfig.json @@ -0,0 +1,55 @@ +// =============================== 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, + // Wasp needs the following settings enable IDE support in your source + // files. Editing them might break features like import autocompletion and + // definition lookup. Don't change them unless you know what you're doing. + // + // The relative path to the generated web app's root directory. This must be + // set to define the "paths" option. + "baseUrl": "../../.wasp/out/web-app/", + "paths": { + // Resolve all "@wasp" imports to the generated source code. + "@wasp/*": [ + "src/*" + ], + // Resolve all non-relative imports to the correct node module. Source: + // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping + "*": [ + // Start by looking for the definiton inside the node modules root + // directory... + "node_modules/*", + // ... If that fails, try to find it inside definitely-typed type + // definitions. + "node_modules/@types/*" + ] + }, + // Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots + "typeRoots": [ + "../../.wasp/out/web-app/node_modules/@types" + ], + // 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/auth-model-experiment/src/client/vite-env.d.ts b/waspc/examples/auth-model-experiment/src/client/vite-env.d.ts new file mode 100644 index 0000000000..1623b9c79c --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/waspc/examples/auth-model-experiment/src/client/vite.config.ts b/waspc/examples/auth-model-experiment/src/client/vite.config.ts new file mode 100644 index 0000000000..a55924e2b6 --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + open: true, + }, +}) diff --git a/waspc/examples/auth-model-experiment/src/client/waspLogo.png b/waspc/examples/auth-model-experiment/src/client/waspLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..d39a9443a8153b158b76f51dda2e42f3b34a9169 GIT binary patch literal 24877 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 left (ER.mkEvaluationError ctx . ER.ParseError . ER.EvaluationParseErrorParsec) $ - makeEntity <$> Parsec.parse Wasp.Psl.Parser.Model.body "" pslString + makeEntity <$> parsePslBody pslString _ -> Left $ mkEvaluationError ctx $ ER.ExpectedType (Type.QuoterType "psl") (TC.AST.exprType expr) + +parsePslBody :: String -> Either Parsec.ParseError Wasp.Psl.Ast.Model.Body +parsePslBody = Parsec.parse Wasp.Psl.Parser.Model.body "" diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index dcf8a58a42..ba4f5a5113 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -28,7 +28,6 @@ import qualified Wasp.AppSpec.App.Db as AS.Db import qualified Wasp.AppSpec.App.Wasp as Wasp import Wasp.AppSpec.Core.Decl (takeDecls) import qualified Wasp.AppSpec.Crud as AS.Crud -import Wasp.AppSpec.Entity (isFieldUnique) import qualified Wasp.AppSpec.Entity as Entity import qualified Wasp.AppSpec.Entity.Field as Entity.Field import qualified Wasp.AppSpec.Page as Page @@ -155,35 +154,7 @@ validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec = cas then [] else validationErrors where - validationErrors = concat [usernameValidationErrors, passwordValidationErrors] - usernameValidationErrors - | not $ null usernameTypeValidationErrors = usernameTypeValidationErrors - | otherwise = usernameAttributeValidationErrors - passwordValidationErrors = - validateEntityHasField - userEntityName - authUserEntityPath - userEntityFields - ("password", Entity.Field.FieldTypeScalar Entity.Field.String, "String") - usernameTypeValidationErrors = - validateEntityHasField - userEntityName - authUserEntityPath - userEntityFields - ("username", Entity.Field.FieldTypeScalar Entity.Field.String, "String") - usernameAttributeValidationErrors - | isFieldUnique "username" userEntity == Just True = [] - | otherwise = - [ GenericValidationError $ - "The field 'username' on entity '" - ++ userEntityName - ++ "' (referenced by " - ++ authUserEntityPath - ++ ") must be marked with the '@unique' attribute." - ] - userEntityFields = Entity.getFields userEntity - authUserEntityPath = "app.auth.userEntity" - (userEntityName, userEntity) = AS.resolveRef spec (Auth.userEntity auth) + validationErrors = [] validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed :: AppSpec -> [ValidationError] validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed spec = case App.auth (snd $ getApp spec) of diff --git a/waspc/src/Wasp/Generator/DbGenerator.hs b/waspc/src/Wasp/Generator/DbGenerator.hs index 6c6ef0cad1..8c59c0a524 100644 --- a/waspc/src/Wasp/Generator/DbGenerator.hs +++ b/waspc/src/Wasp/Generator/DbGenerator.hs @@ -8,12 +8,16 @@ module Wasp.Generator.DbGenerator where import Data.Aeson (object, (.=)) -import Data.Maybe (fromMaybe, maybeToList) +import Data.Maybe (fromJust, fromMaybe, maybeToList) import Data.Text (Text, pack) +import qualified Data.Text as T +import NeatInterpolation (trimming) import StrongPath (Abs, Dir, File, Path', Rel, ()) +import Wasp.Analyzer.StdTypeDefinitions.Entity (parsePslBody) import Wasp.AppSpec (AppSpec, getEntities) 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.Db as AS.Db import qualified Wasp.AppSpec.Entity as AS.Entity import Wasp.AppSpec.Valid (getApp) @@ -42,6 +46,7 @@ import Wasp.Generator.Monad ) import Wasp.Project.Db (databaseUrlEnvVarName) import qualified Wasp.Psl.Ast.Model as Psl.Ast.Model +import qualified Wasp.Psl.Ast.Model as Psl.Model import qualified Wasp.Psl.Generator.Extensions as Psl.Generator.Extensions import qualified Wasp.Psl.Generator.Model as Psl.Generator.Model import Wasp.Util (checksumFromFilePath, hexToString, ifM, (<:>)) @@ -63,9 +68,15 @@ genPrismaSchema spec = do then logAndThrowGeneratorError $ GenericGeneratorError "SQLite (a default database) is not supported in production. To build your Wasp app for production, switch to a different database. Switching to PostgreSQL: https://wasp-lang.dev/docs/data-model/backends#migrating-from-sqlite-to-postgresql ." else return ("sqlite", "\"file:./dev.db\"") + let entities = injectAuthIntoUserEntity $ AS.getDecls @AS.Entity.Entity spec + + authEntities <- makeAuthEntity "String" maybeUserEntityName + + let modelSchemas = map entityToPslModelSchema (entities ++ authEntities) + let templateData = object - [ "modelSchemas" .= map entityToPslModelSchema (AS.getDecls @AS.Entity.Entity spec), + [ "modelSchemas" .= modelSchemas, "datasourceProvider" .= datasourceProvider, "datasourceUrl" .= datasourceUrl, "prismaClientOutputDir" .= makeEnvVarField Wasp.Generator.DbGenerator.Common.prismaClientOutputDirEnvVar, @@ -81,6 +92,47 @@ genPrismaSchema spec = do prismaPreviewFeatures = show <$> (AS.Db.clientPreviewFeatures =<< AS.Db.prisma =<< AS.App.db (snd $ getApp spec)) dbExtensions = Psl.Generator.Extensions.showDbExtensions <$> (AS.Db.dbExtensions =<< AS.Db.prisma =<< AS.App.db (snd $ getApp spec)) + maybeUserEntityName = AS.refName . AS.Auth.userEntity <$> maybeAuth + maybeAuth = AS.App.auth $ snd $ getApp spec + + makeAuthEntity :: String -> Maybe String -> Generator [(String, AS.Entity.Entity)] + makeAuthEntity _ Nothing = return [] + makeAuthEntity userEntityIdType (Just userEntityName) = case parsePslBody authEntityPslBody of + Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err + Right pslBody -> return [("Auth", AS.Entity.makeEntity pslBody)] + where + authEntityPslBody = + T.unpack + [trimming| + id String @id @default(uuid()) + username String @unique + password String + userId ${userEntityIdTypeText}? @unique + user ${userEntityNameText}? @relation(fields: [userId], references: [id]) + |] + + userEntityIdTypeText = T.pack userEntityIdType + userEntityNameText = T.pack userEntityName + + injectAuthIntoUserEntity :: [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)] + injectAuthIntoUserEntity entities = + case maybeUserEntityName of + Nothing -> entities + Just userEntityName -> + let userEntity = fromJust $ lookup userEntityName entities + userEntityWithAuthInjected = injectRelationToAuth userEntity + in (userEntityName, userEntityWithAuthInjected) : filter ((/= userEntityName) . fst) entities + where + injectRelationToAuth :: AS.Entity.Entity -> AS.Entity.Entity + injectRelationToAuth entity = AS.Entity.makeEntity newPslBody + where + (Psl.Model.Body oldPslElements) = AS.Entity.getPslModelBody entity + newPslElements = + [ Psl.Model.ElementField $ Psl.Model.Field "authId" Psl.Model.String [Psl.Model.Optional] [], + Psl.Model.ElementField $ Psl.Model.Field "auth" (Psl.Model.UserType "Auth") [Psl.Model.Optional] [] + ] + newPslBody = Psl.Model.Body $ oldPslElements ++ newPslElements + entityToPslModelSchema :: (String, AS.Entity.Entity) -> String entityToPslModelSchema (entityName, entity) = Psl.Generator.Model.generateModel $ diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index d822b24f02..52716b192a 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -141,6 +141,7 @@ library , extra ^>= 1.7.10 , dotenv ^>= 0.10.0 , network ^>= 3.1.2 + , neat-interpolation ^>=0.5.1.3 other-modules: Paths_waspc exposed-modules: FilePath.Extra From a7e3c1477a515ff97fba0c7cf56ff494a4f28850 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 15 Nov 2023 16:22:41 +0100 Subject: [PATCH 02/62] Username and password working --- .../templates/server/src/_types/index.ts | 5 +- .../server/src/auth/providers/email/login.ts | 4 +- .../providers/email/requestPasswordReset.ts | 4 +- .../src/auth/providers/email/resetPassword.ts | 4 +- .../server/src/auth/providers/email/signup.ts | 16 ++-- .../server/src/auth/providers/email/types.ts | 2 +- .../server/src/auth/providers/local/login.ts | 10 +-- .../server/src/auth/providers/local/signup.ts | 24 +++--- .../server/src/auth/providers/local/types.ts | 2 +- .../server/src/auth/providers/types.ts | 19 +++-- .../templates/server/src/auth/utils.ts | 33 +++++--- .../templates/server/src/core/auth.js | 22 +++++- .../server/src/core/auth/prismaMiddleware.js | 2 +- .../examples/auth-model-experiment/main.wasp | 5 ++ .../migration.sql | 5 +- .../src/client/MainPage.jsx | 65 +++++++++------- .../auth-model-experiment/src/client/auth.jsx | 5 ++ .../src/client/vite.config.ts | 6 +- waspc/src/Wasp/Generator/DbGenerator.hs | 61 +++------------ waspc/src/Wasp/Generator/DbGenerator/Auth.hs | 78 +++++++++++++++++++ waspc/src/Wasp/Generator/ServerGenerator.hs | 3 + .../Wasp/Generator/ServerGenerator/AuthG.hs | 36 +++++---- waspc/waspc.cabal | 1 + 23 files changed, 255 insertions(+), 157 deletions(-) rename waspc/examples/auth-model-experiment/migrations/{20231114165906_initial => 20231115151928_initial}/migration.sql (79%) create mode 100644 waspc/src/Wasp/Generator/DbGenerator/Auth.hs diff --git a/waspc/data/Generator/templates/server/src/_types/index.ts b/waspc/data/Generator/templates/server/src/_types/index.ts index 1c5562f5ab..8b886c0e49 100644 --- a/waspc/data/Generator/templates/server/src/_types/index.ts +++ b/waspc/data/Generator/templates/server/src/_types/index.ts @@ -5,6 +5,7 @@ import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } fr import prisma from "../dbClient.js" {=# isAuthEnabled =} import { type {= userEntityName =} } from "../entities" +import { type {= authEntityName =} } from "@prisma/client" {=/ isAuthEnabled =} import { type _Entity } from "./taggedEntities" import { type Payload } from "./serialization"; @@ -83,5 +84,7 @@ type ContextWithUser = Expand & { // 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 SanitizedUser = Omit<{= userEntityName =}, 'password'> +export type SanitizedUser = {= userEntityName =} & { + {= authFieldOnUserEntityName =}: Omit<{= authEntityName =}, 'password'> +} {=/ isAuthEnabled =} 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 fa53896468..b537719708 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,6 +1,6 @@ import { Request, Response } from 'express'; import { verifyPassword, throwInvalidCredentialsError } from "../../../core/auth.js"; -import { findUserBy, createAuthToken } from "../../utils.js"; +import { findAuthWithUserBy, createAuthToken } from "../../utils.js"; import { ensureValidEmail, ensurePasswordIsPresent } from "../../validation.js"; export function getLoginRoute({ @@ -17,7 +17,7 @@ export function getLoginRoute({ userFields.email = userFields.email.toLowerCase() - const user = await findUserBy({ email: userFields.email }) + const user = await findAuthWithUserBy({ email: userFields.email }) if (!user) { throwInvalidCredentialsError() } 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 e665240dd2..b7c01ce1c6 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 @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { - findUserBy, + findAuthWithUserBy, doFakeWork, } from "../../utils.js"; import { @@ -30,7 +30,7 @@ export function getRequestPasswordResetRoute({ args.email = args.email.toLowerCase(); - const user = await findUserBy({ email: args.email }); + const user = await findAuthWithUserBy({ email: args.email }); // User not found or not verified - don't leak information if (!user || !user.isEmailVerified) { 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 d7dce9f0e9..fc7b82abb7 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 @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { findUserBy, verifyToken } from "../../utils.js"; +import { findAuthWithUserBy, verifyToken } from "../../utils.js"; import { updateUserPassword } from "./utils.js"; import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js"; import { tokenVerificationErrors } from "./types.js"; @@ -14,7 +14,7 @@ export async function resetPassword( const { token, password } = args; try { const { id: userId } = await verifyToken(token); - const user = await findUserBy({ id: userId }); + const user = await findAuthWithUserBy({ id: userId }); if (!user) { return res.status(400).json({ success: false, message: 'Invalid token' }); } 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 3ad9d795a2..200cb3270e 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 @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { EmailFromField } from "../../../email/core/types.js"; import { createUser, - findUserBy, + findAuthWithUserBy, deleteUser, doFakeWork, } from "../../utils.js"; @@ -33,7 +33,7 @@ export function getSignupRoute({ userFields.email = userFields.email.toLowerCase(); - const existingUser = await findUserBy({ email: userFields.email }); + const existingUser = await findAuthWithUserBy({ email: userFields.email }); // User already exists and is verified - don't leak information if (existingUser && existingUser.isEmailVerified) { await doFakeWork(); @@ -47,11 +47,13 @@ export function getSignupRoute({ const additionalFields = await validateAndGetAdditionalFields(userFields); - const user = await createUser({ - ...additionalFields, - email: userFields.email, - password: userFields.password, - }); + const user = await createUser( + { + email: userFields.email, + password: userFields.password, + }, + additionalFields, + ); const verificationLink = await createEmailVerificationLink(user, clientRoute); try { diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts index d1a7bc4a13..ac335bf9d2 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts @@ -14,4 +14,4 @@ export const tokenVerificationErrors = { TokenExpiredError: 'TokenExpiredError', }; -export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"email" | "password">() +export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn() diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts index af566844eb..dc504731e5 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts @@ -2,26 +2,26 @@ import { verifyPassword, throwInvalidCredentialsError } from '../../../core/auth.js' import { handleRejection } from '../../../utils.js' -import { findUserBy, createAuthToken } from '../../utils.js' +import { findAuthWithUserBy, createAuthToken } from '../../utils.js' import { ensureValidUsername, ensurePasswordIsPresent } from '../../validation.js' export default handleRejection(async (req, res) => { const userFields = req.body || {} ensureValidArgs(userFields) - const user = await findUserBy({ username: userFields.username }) - if (!user) { + const auth = await findAuthWithUserBy({ username: userFields.username }) + if (!auth) { throwInvalidCredentialsError() } try { - await verifyPassword(user.password, userFields.password) + await verifyPassword(auth.password, userFields.password) } catch(e) { throwInvalidCredentialsError() } // Username & password valid - generate token. - const token = await createAuthToken(user) + const token = await createAuthToken(auth) // NOTE(matija): Possible option - instead of explicitly returning token here, // we could add to response header 'Set-Cookie {token}' directive which would then make diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts index 8705e411d1..b74535ffe3 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts @@ -1,7 +1,11 @@ {{={= =}=}} import { handleRejection } from '../../../utils.js' import { createUser } from '../../utils.js' -import { ensureValidUsername, ensurePasswordIsPresent, ensureValidPassword } from '../../validation.js' +import { + ensureValidUsername, + ensurePasswordIsPresent, + ensureValidPassword, +} from '../../validation.js' import { validateAndGetAdditionalFields } from '../../utils.js' export default handleRejection(async (req, res) => { @@ -10,17 +14,19 @@ export default handleRejection(async (req, res) => { const additionalFields = await validateAndGetAdditionalFields(userFields) - await createUser({ - ...additionalFields, - username: userFields.username, - password: userFields.password, - }) + await createUser( + { + username: userFields.username, + password: userFields.password, + }, + additionalFields + ) return res.json({ success: true }) }) function ensureValidArgs(args: unknown): void { - ensureValidUsername(args); - ensurePasswordIsPresent(args); - ensureValidPassword(args); + ensureValidUsername(args) + ensurePasswordIsPresent(args) + ensureValidPassword(args) } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts index 5d72e7d7ab..29cc2bfc3d 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts @@ -1,3 +1,3 @@ import { createDefineAdditionalSignupFieldsFn } from '../types.js' -export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"username" | "password">() +export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn() 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 0889298c0a..f7295056c8 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,10 @@ +{{={= =}=}} import type { Router, Request } from 'express' -import type { User } from '../../entities' +import type { {= userEntityUpper =} } from '../../entities' import type { Expand } from '../../universal/types' +type UserEntity = {= userEntityUpper =} + export type ProviderConfig = { // Unique provider identifier, used as part of URL paths id: string; @@ -20,16 +23,12 @@ export type InitData = { export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } -export function createDefineAdditionalSignupFieldsFn< - // Wasp already includes these fields in the signup process - ExistingFields extends keyof User, - PossibleAdditionalFields = Expand< - Partial> - > ->() { +export type PossibleAdditionalSignupFields = Expand> + +export function createDefineAdditionalSignupFieldsFn() { return function defineFields(config: { - [key in keyof PossibleAdditionalFields]: FieldGetter< - PossibleAdditionalFields[key] + [key in keyof PossibleAdditionalSignupFields]: FieldGetter< + PossibleAdditionalSignupFields[key] > }) { return config diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index 5495937b56..e835a9346a 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -5,7 +5,7 @@ import HttpError from '../core/HttpError.js' import prisma from '../dbClient.js' import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js' import { type {= userEntityUpper =} } from '../entities/index.js' -import { type Prisma } from '@prisma/client'; +import { type Prisma, type {= authEntityUpper =} } from '@prisma/client'; import { throwValidationError } from './validation.js' @@ -13,13 +13,13 @@ import { throwValidationError } from './validation.js' {=& additionalSignupFields.importStatement =} {=/ additionalSignupFields.isDefined =} +import { createDefineAdditionalSignupFieldsFn, type PossibleAdditionalSignupFields } from './providers/types.js' {=# additionalSignupFields.isDefined =} const _waspAdditionalSignupFieldsConfig = {= additionalSignupFields.importIdentifier =} {=/ additionalSignupFields.isDefined =} {=^ additionalSignupFields.isDefined =} -import { createDefineAdditionalSignupFieldsFn } from './providers/types.js' const _waspAdditionalSignupFieldsConfig = {} as ReturnType< - ReturnType> + ReturnType > {=/ additionalSignupFields.isDefined =} @@ -34,28 +34,39 @@ export const authConfig = { successRedirectPath: "{= successRedirectPath =}", } -export async function findUserBy(where: Prisma.{= userEntityUpper =}WhereUniqueInput): Promise<{= userEntityUpper =}> { - return prisma.{= userEntityLower =}.findUnique({ where }); +export async function findAuthWithUserBy(where: Prisma.{= authEntityUpper =}WhereUniqueInput) { + return prisma.{= authEntityLower =}.findUnique({ where, include: { {= userFieldOnAuthEntityName =}: true }}); } -export async function createUser(data: Prisma.{= userEntityUpper =}CreateInput): Promise<{= userEntityUpper =}> { +export async function createUser(data: Prisma.{= authEntityUpper =}CreateInput, additionalFields: PossibleAdditionalSignupFields) { try { - return await prisma.{= userEntityLower =}.create({ data }) + return await prisma.{= authEntityLower =}.create({ + data: { + ...data, + {= userFieldOnAuthEntityName =}: { + create: { + ...additionalFields, + } + } + } + }) } catch (e) { rethrowPossiblePrismaError(e); } } -export async function deleteUser(user: {= userEntityUpper =}): Promise<{= userEntityUpper =}> { +export async function deleteUser(auth: {= authEntityUpper =}) { try { - return await prisma.{= userEntityLower =}.delete({ where: { id: user.id } }) + return await prisma.{= authEntityLower =}.delete({ where: { id: auth.id } }) } catch (e) { rethrowPossiblePrismaError(e); } } -export async function createAuthToken(user: {= userEntityUpper =}): Promise { - return sign(user.id); +export async function createAuthToken( + auth: {= authEntityUpper =} & { {= userFieldOnAuthEntityName =}: {= userEntityUpper =} } +): Promise { + return sign(auth.{= userFieldOnAuthEntityName =}.id); } export async function verifyToken(token: string): Promise<{ id: any }> { diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js index 06f9b20dc8..de76731140 100644 --- a/waspc/data/Generator/templates/server/src/core/auth.js +++ b/waspc/data/Generator/templates/server/src/core/auth.js @@ -48,7 +48,13 @@ export async function getUserFromToken(token) { } } - const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: userIdFromToken } }) + const user = await prisma.{= userEntityLower =} + .findUnique({ + where: { id: userIdFromToken }, + include: { + {= authFieldOnUserEntityName =}: true + } + }) if (!user) { throwInvalidCredentialsError() } @@ -57,9 +63,17 @@ export async function getUserFromToken(token) { // 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 { password, ...userView } = user - - return userView + const { + {= authFieldOnUserEntityName =}: { password, ...{= authFieldOnUserEntityName =}Fields }, + ...fields + } = user + + return { + ...fields, + auth: { + ...{= authFieldOnUserEntityName =}Fields + } + } } const SP = new SecurePassword() diff --git a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js index 6670f2363c..48aac207df 100644 --- a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js +++ b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js @@ -5,7 +5,7 @@ import { PASSWORD_FIELD } from '../../auth/validation.js' // Make sure password is always hashed before storing to the database. const registerPasswordHashing = (prismaClient) => { prismaClient.$use(async (params, next) => { - if (params.model === '{= userEntityUpper =}') { + if (params.model === '{= authEntityUpper =}') { if (['create', 'update', 'updateMany'].includes(params.action)) { if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) diff --git a/waspc/examples/auth-model-experiment/main.wasp b/waspc/examples/auth-model-experiment/main.wasp index 50b846aead..73cf226e12 100644 --- a/waspc/examples/auth-model-experiment/main.wasp +++ b/waspc/examples/auth-model-experiment/main.wasp @@ -21,6 +21,11 @@ entity User {=psl id String @id @default(uuid()) psl=} +route LoginRoute { path: "/login", to: Login } +page Login { + component: import { Login } from "@client/auth.jsx" +} + route SignupRoute { path: "/signup", to: Signup } page Signup { component: import { Signup } from "@client/auth.jsx" diff --git a/waspc/examples/auth-model-experiment/migrations/20231114165906_initial/migration.sql b/waspc/examples/auth-model-experiment/migrations/20231115151928_initial/migration.sql similarity index 79% rename from waspc/examples/auth-model-experiment/migrations/20231114165906_initial/migration.sql rename to waspc/examples/auth-model-experiment/migrations/20231115151928_initial/migration.sql index 350a379ca7..6d31ce0230 100644 --- a/waspc/examples/auth-model-experiment/migrations/20231114165906_initial/migration.sql +++ b/waspc/examples/auth-model-experiment/migrations/20231115151928_initial/migration.sql @@ -1,7 +1,6 @@ -- CreateTable CREATE TABLE "User" ( - "id" TEXT NOT NULL PRIMARY KEY, - "authId" TEXT + "id" TEXT NOT NULL PRIMARY KEY ); -- CreateTable @@ -10,7 +9,7 @@ CREATE TABLE "Auth" ( "username" TEXT NOT NULL, "password" TEXT NOT NULL, "userId" TEXT, - CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); -- CreateIndex diff --git a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx index c53ad8abaa..fe8c26e65c 100644 --- a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx +++ b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx @@ -1,7 +1,13 @@ -import waspLogo from './waspLogo.png' -import './Main.css' +import waspLogo from "./waspLogo.png"; +import "./Main.css"; + +import logout from "@wasp/auth/logout"; +import useAuth from "@wasp/auth/useAuth"; + +import { Link } from "@wasp/router"; const MainPage = () => { + const { data: user } = useAuth(); return (
@@ -9,32 +15,35 @@ const MainPage = () => { wasp
-

Welcome to Wasp - you just started a new app!

-

- This is page MainPage located at route /. - Open src/client/MainPage.jsx to edit it. -

+

+ {" "} + Welcome to Wasp - you just started a new app!{" "} +

- + {user && ( +
+

+ {" "} + You are logged in as {user.auth.username}!{" "} +

+

+ {" "} + Your user id is {user.id}.{" "} +

+
+ )} + + {user && ( + + )} + + + Login + - ) -} -export default MainPage + ); +}; +export default MainPage; diff --git a/waspc/examples/auth-model-experiment/src/client/auth.jsx b/waspc/examples/auth-model-experiment/src/client/auth.jsx index 9d9869f5bb..a2087045aa 100644 --- a/waspc/examples/auth-model-experiment/src/client/auth.jsx +++ b/waspc/examples/auth-model-experiment/src/client/auth.jsx @@ -1,5 +1,10 @@ import { SignupForm } from "@wasp/auth/forms/Signup"; +import { LoginForm } from "@wasp/auth/forms/Login"; export function Signup() { return ; } + +export function Login() { + return ; +} diff --git a/waspc/examples/auth-model-experiment/src/client/vite.config.ts b/waspc/examples/auth-model-experiment/src/client/vite.config.ts index a55924e2b6..6e16a4c901 100644 --- a/waspc/examples/auth-model-experiment/src/client/vite.config.ts +++ b/waspc/examples/auth-model-experiment/src/client/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' +import { defineConfig } from "vite"; export default defineConfig({ server: { - open: true, + open: false, }, -}) +}); diff --git a/waspc/src/Wasp/Generator/DbGenerator.hs b/waspc/src/Wasp/Generator/DbGenerator.hs index 8c59c0a524..be45302b9a 100644 --- a/waspc/src/Wasp/Generator/DbGenerator.hs +++ b/waspc/src/Wasp/Generator/DbGenerator.hs @@ -8,12 +8,10 @@ module Wasp.Generator.DbGenerator where import Data.Aeson (object, (.=)) -import Data.Maybe (fromJust, fromMaybe, maybeToList) +import Data.List (find) +import Data.Maybe (fromMaybe, maybeToList) import Data.Text (Text, pack) -import qualified Data.Text as T -import NeatInterpolation (trimming) import StrongPath (Abs, Dir, File, Path', Rel, ()) -import Wasp.Analyzer.StdTypeDefinitions.Entity (parsePslBody) import Wasp.AppSpec (AppSpec, getEntities) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App @@ -22,6 +20,7 @@ import qualified Wasp.AppSpec.App.Db as AS.Db import qualified Wasp.AppSpec.Entity as AS.Entity import Wasp.AppSpec.Valid (getApp) import Wasp.Generator.Common (ProjectRootDir) +import qualified Wasp.Generator.DbGenerator.Auth as DbAuth import Wasp.Generator.DbGenerator.Common ( DbSchemaChecksumFile, DbSchemaChecksumOnLastDbConcurrenceFile, @@ -46,7 +45,6 @@ import Wasp.Generator.Monad ) import Wasp.Project.Db (databaseUrlEnvVarName) import qualified Wasp.Psl.Ast.Model as Psl.Ast.Model -import qualified Wasp.Psl.Ast.Model as Psl.Model import qualified Wasp.Psl.Generator.Extensions as Psl.Generator.Extensions import qualified Wasp.Psl.Generator.Model as Psl.Generator.Model import Wasp.Util (checksumFromFilePath, hexToString, ifM, (<:>)) @@ -68,15 +66,11 @@ genPrismaSchema spec = do then logAndThrowGeneratorError $ GenericGeneratorError "SQLite (a default database) is not supported in production. To build your Wasp app for production, switch to a different database. Switching to PostgreSQL: https://wasp-lang.dev/docs/data-model/backends#migrating-from-sqlite-to-postgresql ." else return ("sqlite", "\"file:./dev.db\"") - let entities = injectAuthIntoUserEntity $ AS.getDecls @AS.Entity.Entity spec - - authEntities <- makeAuthEntity "String" maybeUserEntityName - - let modelSchemas = map entityToPslModelSchema (entities ++ authEntities) + entities <- DbAuth.injectAuth maybeUserEntity userDefinedEntities let templateData = object - [ "modelSchemas" .= modelSchemas, + [ "modelSchemas" .= map entityToPslModelSchema entities, "datasourceProvider" .= datasourceProvider, "datasourceUrl" .= datasourceUrl, "prismaClientOutputDir" .= makeEnvVarField Wasp.Generator.DbGenerator.Common.prismaClientOutputDirEnvVar, @@ -92,46 +86,13 @@ genPrismaSchema spec = do prismaPreviewFeatures = show <$> (AS.Db.clientPreviewFeatures =<< AS.Db.prisma =<< AS.App.db (snd $ getApp spec)) dbExtensions = Psl.Generator.Extensions.showDbExtensions <$> (AS.Db.dbExtensions =<< AS.Db.prisma =<< AS.App.db (snd $ getApp spec)) - maybeUserEntityName = AS.refName . AS.Auth.userEntity <$> maybeAuth - maybeAuth = AS.App.auth $ snd $ getApp spec - - makeAuthEntity :: String -> Maybe String -> Generator [(String, AS.Entity.Entity)] - makeAuthEntity _ Nothing = return [] - makeAuthEntity userEntityIdType (Just userEntityName) = case parsePslBody authEntityPslBody of - Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err - Right pslBody -> return [("Auth", AS.Entity.makeEntity pslBody)] - where - authEntityPslBody = - T.unpack - [trimming| - id String @id @default(uuid()) - username String @unique - password String - userId ${userEntityIdTypeText}? @unique - user ${userEntityNameText}? @relation(fields: [userId], references: [id]) - |] - - userEntityIdTypeText = T.pack userEntityIdType - userEntityNameText = T.pack userEntityName + userDefinedEntities = getEntities spec - injectAuthIntoUserEntity :: [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)] - injectAuthIntoUserEntity entities = - case maybeUserEntityName of - Nothing -> entities - Just userEntityName -> - let userEntity = fromJust $ lookup userEntityName entities - userEntityWithAuthInjected = injectRelationToAuth userEntity - in (userEntityName, userEntityWithAuthInjected) : filter ((/= userEntityName) . fst) entities - where - injectRelationToAuth :: AS.Entity.Entity -> AS.Entity.Entity - injectRelationToAuth entity = AS.Entity.makeEntity newPslBody - where - (Psl.Model.Body oldPslElements) = AS.Entity.getPslModelBody entity - newPslElements = - [ Psl.Model.ElementField $ Psl.Model.Field "authId" Psl.Model.String [Psl.Model.Optional] [], - Psl.Model.ElementField $ Psl.Model.Field "auth" (Psl.Model.UserType "Auth") [Psl.Model.Optional] [] - ] - newPslBody = Psl.Model.Body $ oldPslElements ++ newPslElements + maybeUserEntity :: Maybe (String, AS.Entity.Entity) + maybeUserEntity = do + auth <- AS.App.auth $ snd $ getApp spec + let userEntityName = AS.refName . AS.Auth.userEntity $ auth + find ((== userEntityName) . fst) userDefinedEntities entityToPslModelSchema :: (String, AS.Entity.Entity) -> String entityToPslModelSchema (entityName, entity) = diff --git a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs new file mode 100644 index 0000000000..d9e3be9803 --- /dev/null +++ b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs @@ -0,0 +1,78 @@ +module Wasp.Generator.DbGenerator.Auth where + +import Data.Function ((&)) +import Data.Maybe +import qualified Data.Text as T +import NeatInterpolation (trimming) +import Wasp.Analyzer.StdTypeDefinitions.Entity (parsePslBody) +import qualified Wasp.AppSpec.Entity as AS.Entity +import Wasp.Generator.Monad + ( Generator, + GeneratorError (GenericGeneratorError), + logAndThrowGeneratorError, + ) +import qualified Wasp.Psl.Ast.Model as Psl.Model +import qualified Wasp.Psl.Ast.Model as Psl.Model.Field + +authEntityName :: String +authEntityName = "Auth" + +userFieldOnAuthEntityName :: String +userFieldOnAuthEntityName = "user" + +authFieldOnUserEntityName :: String +authFieldOnUserEntityName = "auth" + +injectAuth :: Maybe (String, AS.Entity.Entity) -> [(String, AS.Entity.Entity)] -> Generator [(String, AS.Entity.Entity)] +injectAuth Nothing entities = return entities +injectAuth (Just (userEntityName, userEntity)) entities = do + userEntityIdType <- getUserEntityId userEntity + authEntity <- makeAuthEntity userEntityIdType userEntityName + return $ injectAuthIntoUserEntity userEntityName $ entities ++ authEntity + +getUserEntityId :: AS.Entity.Entity -> Generator String +getUserEntityId entity = + show . Psl.Model.Field._type <$> AS.Entity.getIdField entity + & ( \case + Nothing -> logAndThrowGeneratorError $ GenericGeneratorError "User entity does not have an id field." + Just idType -> return idType + ) + +makeAuthEntity :: String -> String -> Generator [(String, AS.Entity.Entity)] +makeAuthEntity userEntityIdType userEntityName = case parsePslBody authEntityPslBody of + Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err + Right pslBody -> return [(authEntityName, AS.Entity.makeEntity pslBody)] + where + authEntityPslBody = + T.unpack + [trimming| + id String @id @default(uuid()) + username String @unique + password String + userId ${userEntityIdTypeText}? @unique + ${userFieldOnAuthEntityNameText} ${userEntityNameText}? @relation(fields: [userId], references: [id], onDelete: Cascade) + |] + + userEntityIdTypeText = T.pack userEntityIdType + userEntityNameText = T.pack userEntityName + userFieldOnAuthEntityNameText = T.pack userFieldOnAuthEntityName + +injectAuthIntoUserEntity :: String -> [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)] +injectAuthIntoUserEntity userEntityName entities = + let userEntity = fromJust $ lookup userEntityName entities + userEntityWithAuthInjected = injectRelationToAuth userEntity + in (userEntityName, userEntityWithAuthInjected) : filter ((/= userEntityName) . fst) entities + where + injectRelationToAuth :: AS.Entity.Entity -> AS.Entity.Entity + injectRelationToAuth entity = AS.Entity.makeEntity newPslBody + where + (Psl.Model.Body existingPsl) = AS.Entity.getPslModelBody entity + relationToAuthEntity = + [ Psl.Model.ElementField $ + Psl.Model.Field + authFieldOnUserEntityName + (Psl.Model.UserType authEntityName) + [Psl.Model.Optional] + [] + ] + newPslBody = Psl.Model.Body $ existingPsl ++ relationToAuthEntity diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index a5df648336..634a72b251 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -47,6 +47,7 @@ import Wasp.Generator.Common 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) @@ -316,6 +317,8 @@ genTypesAndEntitiesDirs spec = [ "entities" .= allEntities, "isAuthEnabled" .= isJust maybeUserEntityName, "userEntityName" .= userEntityName, + "authEntityName" .= DbAuth.authEntityName, + "authFieldOnUserEntityName" .= DbAuth.authFieldOnUserEntityName, "userFieldName" .= toLowerFirst userEntityName ] ) diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index 7b057d9b7c..b1d3d73598 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -19,11 +19,12 @@ 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 Wasp.AppSpec.Valid (doesUserEntityContainField, getApp) +import Wasp.AppSpec.Valid (getApp) import Wasp.Generator.AuthProviders (emailAuthProvider, gitHubAuthProvider, googleAuthProvider, localAuthProvider) import qualified Wasp.Generator.AuthProviders.Email as EmailProvider import qualified Wasp.Generator.AuthProviders.Local as LocalProvider import qualified Wasp.Generator.AuthProviders.OAuth as OAuthProvider +import qualified Wasp.Generator.DbGenerator.Auth as DbAuth import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import Wasp.Generator.ServerGenerator.Auth.EmailAuthG (genEmailAuth) @@ -39,12 +40,12 @@ genAuth spec = case maybeAuth of Just auth -> sequence [ genCoreAuth auth, - genAuthMiddleware spec auth, + genAuthMiddleware, genAuthRoutesIndex auth, genMeRoute auth, genUtils auth, genProvidersIndex auth, - genFileCopy [relfile|auth/providers/types.ts|], + genProvidersTypes auth, genFileCopy [relfile|auth/validation.ts|] ] <++> genIndexTs auth @@ -68,11 +69,12 @@ genCoreAuth auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmpl let userEntityName = AS.refName $ AS.Auth.userEntity auth in object [ "userEntityUpper" .= (userEntityName :: String), - "userEntityLower" .= (Util.toLowerFirst userEntityName :: String) + "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), + "authFieldOnUserEntityName" .= (DbAuth.authFieldOnUserEntityName :: String) ] -genAuthMiddleware :: AS.AppSpec -> AS.Auth.Auth -> Generator FileDraft -genAuthMiddleware spec auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) +genAuthMiddleware :: Generator FileDraft +genAuthMiddleware = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) where -- TODO(martin): In prismaMiddleware.js, we assume that 'username' and 'password' are defined in user entity. -- This was promised to us by AppSpec, which has validation checks for this. @@ -84,17 +86,7 @@ genAuthMiddleware spec auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile tmplFile = C.asTmplFile $ [reldir|src|] authMiddlewareRelToSrc dstFile = C.serverSrcDirInServerRootDir C.asServerSrcFile authMiddlewareRelToSrc - tmplData = - let userEntityName = AS.refName $ AS.Auth.userEntity auth - isPasswordOnUserEntity = doesUserEntityContainField spec "password" == Just True - isUsernameOnUserEntity = doesUserEntityContainField spec "username" == Just True - in object - [ "userEntityUpper" .= userEntityName, - "isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth, - "isPasswordOnUserEntity" .= isPasswordOnUserEntity, - "isUsernameOnUserEntity" .= isUsernameOnUserEntity, - "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth - ] + tmplData = object ["authEntityUpper" .= (DbAuth.authEntityName :: String)] genAuthRoutesIndex :: AS.Auth.Auth -> Generator FileDraft genAuthRoutesIndex auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) @@ -126,6 +118,9 @@ genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplDat object [ "userEntityUpper" .= (userEntityName :: String), "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), + "authEntityUpper" .= (DbAuth.authEntityName :: String), + "authEntityLower" .= (Util.toLowerFirst DbAuth.authEntityName :: String), + "userFieldOnAuthEntityName" .= (DbAuth.userFieldOnAuthEntityName :: String), "failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo auth, "successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth, "additionalSignupFields" .= extImportToImportJson [reldirP|../|] additionalSignupFields @@ -166,3 +161,10 @@ genProvidersIndex auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers [LocalProvider.providerId localAuthProvider | AS.Auth.isUsernameAndPasswordAuthEnabled auth], [EmailProvider.providerId emailAuthProvider | AS.Auth.isEmailAuthEnabled auth] ] + +genProvidersTypes :: AS.Auth.Auth -> Generator FileDraft +genProvidersTypes auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers/types.ts|] (Just tmplData) + where + userEntityName = AS.refName $ AS.Auth.userEntity auth + + tmplData = object ["userEntityUpper" .= (userEntityName :: String)] diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index 52716b192a..1148a7c6a3 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -242,6 +242,7 @@ library Wasp.Generator.ConfigFile Wasp.Generator.ConfigFileGenerator Wasp.Generator.DbGenerator + Wasp.Generator.DbGenerator.Auth Wasp.Generator.DbGenerator.Common Wasp.Generator.DbGenerator.Jobs Wasp.Generator.DbGenerator.Operations From bf77d11e6eb29e9fbf7529d5034ef8a4a2b87c53 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 15 Nov 2023 17:18:57 +0100 Subject: [PATCH 03/62] Add support for email auth --- .../server/src/auth/providers/email/login.ts | 10 ++--- .../providers/email/requestPasswordReset.ts | 12 +++--- .../src/auth/providers/email/resetPassword.ts | 10 ++--- .../server/src/auth/providers/email/signup.ts | 17 ++++---- .../server/src/auth/providers/email/utils.ts | 43 ++++++++++--------- .../src/auth/providers/email/verifyEmail.ts | 6 +-- .../server/src/auth/providers/local/signup.ts | 4 +- .../templates/server/src/auth/utils.ts | 6 +-- .../examples/auth-model-experiment/main.wasp | 38 +++++++++++++++- .../migration.sql | 11 ++++- .../src/client/MainPage.jsx | 2 +- .../auth-model-experiment/src/client/auth.jsx | 15 +++++++ waspc/src/Wasp/AppSpec/Valid.hs | 30 ------------- waspc/src/Wasp/Generator/DbGenerator/Auth.hs | 12 ++++-- .../ServerGenerator/Auth/EmailAuthG.hs | 5 ++- 15 files changed, 129 insertions(+), 92 deletions(-) rename waspc/examples/auth-model-experiment/migrations/{20231115151928_initial => 20231115160416_initial}/migration.sql (62%) 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 b537719708..33e16ab005 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 @@ -17,20 +17,20 @@ export function getLoginRoute({ userFields.email = userFields.email.toLowerCase() - const user = await findAuthWithUserBy({ email: userFields.email }) - if (!user) { + const auth = await findAuthWithUserBy({ email: userFields.email }) + if (!auth) { throwInvalidCredentialsError() } - if (!user.isEmailVerified && !allowUnverifiedLogin) { + if (!auth.isEmailVerified && !allowUnverifiedLogin) { throwInvalidCredentialsError() } try { - await verifyPassword(user.password, userFields.password); + await verifyPassword(auth.password, userFields.password); } catch(e) { throwInvalidCredentialsError() } - const token = await createAuthToken(user) + const token = await createAuthToken(auth) return res.json({ token }) }; 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 b7c01ce1c6..ef772a99a8 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 @@ -30,25 +30,25 @@ export function getRequestPasswordResetRoute({ args.email = args.email.toLowerCase(); - const user = await findAuthWithUserBy({ email: args.email }); + const auth = await findAuthWithUserBy({ email: args.email }); // User not found or not verified - don't leak information - if (!user || !user.isEmailVerified) { + if (!auth || !auth.isEmailVerified) { await doFakeWork(); return res.json({ success: true }); } - if (!isEmailResendAllowed(user, 'passwordResetSentAt')) { + if (!isEmailResendAllowed(auth, 'passwordResetSentAt')) { return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); } - const passwordResetLink = await createPasswordResetLink(user, clientRoute); + const passwordResetLink = await createPasswordResetLink(auth, clientRoute); try { await sendPasswordResetEmail( - user.email, + auth.email, { from: fromField, - to: user.email, + to: auth.email, ...getPasswordResetEmailContent({ passwordResetLink }), } ); 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 fc7b82abb7..c2cd94cf42 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 @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { findAuthWithUserBy, verifyToken } from "../../utils.js"; -import { updateUserPassword } from "./utils.js"; +import { updateAuthPassword } from "./utils.js"; import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js"; import { tokenVerificationErrors } from "./types.js"; @@ -13,12 +13,12 @@ export async function resetPassword( const { token, password } = args; try { - const { id: userId } = await verifyToken(token); - const user = await findAuthWithUserBy({ id: userId }); - if (!user) { + const { id: authId } = await verifyToken(token); + const auth = await findAuthWithUserBy({ id: authId }); + if (!auth) { return res.status(400).json({ success: false, message: 'Invalid token' }); } - await updateUserPassword(userId, password); + await updateAuthPassword(authId, password); } catch (e) { const reason = e.name === tokenVerificationErrors.TokenExpiredError ? 'expired' 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 200cb3270e..a9747faa49 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 @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { EmailFromField } from "../../../email/core/types.js"; import { - createUser, + createAuthWithUser, findAuthWithUserBy, deleteUser, doFakeWork, @@ -33,21 +33,21 @@ export function getSignupRoute({ userFields.email = userFields.email.toLowerCase(); - const existingUser = await findAuthWithUserBy({ email: userFields.email }); + const existingAuth = await findAuthWithUserBy({ email: userFields.email }); // User already exists and is verified - don't leak information - if (existingUser && existingUser.isEmailVerified) { + if (existingAuth && existingAuth.isEmailVerified) { await doFakeWork(); return res.json({ success: true }); - } else if (existingUser && !existingUser.isEmailVerified) { - if (!isEmailResendAllowed(existingUser, 'emailVerificationSentAt')) { + } else if (existingAuth && !existingAuth.isEmailVerified) { + if (!isEmailResendAllowed(existingAuth, 'emailVerificationSentAt')) { return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); } - await deleteUser(existingUser); + await deleteUser(existingAuth); } const additionalFields = await validateAndGetAdditionalFields(userFields); - const user = await createUser( + const auth = await createAuthWithUser( { email: userFields.email, password: userFields.password, @@ -55,7 +55,8 @@ export function getSignupRoute({ additionalFields, ); - const verificationLink = await createEmailVerificationLink(user, clientRoute); + // TODO: use user here + const verificationLink = await createEmailVerificationLink(auth, clientRoute); try { await sendEmailVerificationEmail( userFields.email, 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 814325e181..b002c361d8 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 @@ -6,13 +6,14 @@ import { rethrowPossiblePrismaError } from '../../utils.js' import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js'; import { type {= userEntityUpper =} } from '../../../entities/index.js' +import { type {= authEntityUpper =} } from '@prisma/client'; -type {= userEntityUpper =}Id = {= userEntityUpper =}['id'] +type {= authEntityUpper =}Id = {= authEntityUpper =}['id'] -export async function updateUserEmailVerification(userId: {= userEntityUpper =}Id): Promise { +export async function updateAuthEmailVerification(authId: {= authEntityUpper =}Id) { try { - await prisma.{= userEntityLower =}.update({ - where: { id: userId }, + await prisma.{= authEntityLower =}.update({ + where: { id: authId }, data: { isEmailVerified: true }, }) } catch (e) { @@ -20,10 +21,10 @@ export async function updateUserEmailVerification(userId: {= userEntityUpper =}I } } -export async function updateUserPassword(userId: {= userEntityUpper =}Id, password: string): Promise { +export async function updateAuthPassword(authId: {= authEntityUpper =}Id, password: string) { try { - await prisma.{= userEntityLower =}.update({ - where: { id: userId }, + await prisma.{= authEntityLower =}.update({ + where: { id: authId }, data: { password }, }) } catch (e) { @@ -31,35 +32,35 @@ export async function updateUserPassword(userId: {= userEntityUpper =}Id, passwo } } -export async function createEmailVerificationLink(user: {= userEntityUpper =}, clientRoute: string): Promise { - const token = await createEmailVerificationToken(user); +export async function createEmailVerificationLink(auth: {= authEntityUpper =}, clientRoute: string) { + const token = await createEmailVerificationToken(auth); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -export async function createPasswordResetLink(user: {= userEntityUpper =}, clientRoute: string): Promise { - const token = await createPasswordResetToken(user); +export async function createPasswordResetLink(auth: {= authEntityUpper =}, clientRoute: string) { + const token = await createPasswordResetToken(auth); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -async function createEmailVerificationToken(user: {= userEntityUpper =}): Promise { - return sign(user.id, { expiresIn: '30m' }); +async function createEmailVerificationToken(auth: {= authEntityUpper =}): Promise { + return sign(auth.id, { expiresIn: '30m' }); } -async function createPasswordResetToken(user: {= userEntityUpper =}): Promise { - return sign(user.id, { expiresIn: '30m' }); +async function createPasswordResetToken(auth: {= authEntityUpper =}): Promise { + return sign(auth.id, { expiresIn: '30m' }); } export async function sendPasswordResetEmail( email: string, content: Email, -): Promise { +) { return sendEmailAndLogTimestamp(email, content, 'passwordResetSentAt'); } export async function sendEmailVerificationEmail( email: string, content: Email, -): Promise { +) { return sendEmailAndLogTimestamp(email, content, 'emailVerificationSentAt'); } @@ -67,12 +68,12 @@ async function sendEmailAndLogTimestamp( email: string, content: Email, field: 'emailVerificationSentAt' | 'passwordResetSentAt', -): Promise { +) { // Set the timestamp first, and then send the email // so the user can't send multiple requests while // the email is being sent. try { - await prisma.{= userEntityLower =}.update({ + await prisma.{= authEntityLower =}.update({ where: { email }, data: { [field]: new Date() }, }) @@ -85,11 +86,11 @@ async function sendEmailAndLogTimestamp( } export function isEmailResendAllowed( - user: {= userEntityUpper =}, + auth: {= authEntityUpper =}, field: 'emailVerificationSentAt' | 'passwordResetSentAt', resendInterval: number = 1000 * 60, ): boolean { - const sentAt = user[field]; + const sentAt = auth[field]; if (!sentAt) { return true; } 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 e152591e41..adc3309981 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 @@ -1,5 +1,5 @@ import { Request, Response } from 'express'; -import { updateUserEmailVerification } from './utils.js'; +import { updateAuthEmailVerification } from './utils.js'; import { verifyToken } from '../../utils.js'; import { tokenVerificationErrors } from './types.js'; @@ -9,8 +9,8 @@ export async function verifyEmail( ): Promise> { try { const { token } = req.body; - const { id: userId } = await verifyToken(token); - await updateUserEmailVerification(userId); + const { id: authId } = await verifyToken(token); + await updateAuthEmailVerification(authId); } catch (e) { const reason = e.name === tokenVerificationErrors.TokenExpiredError ? 'expired' diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts index b74535ffe3..1839b2dc78 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts @@ -1,6 +1,6 @@ {{={= =}=}} import { handleRejection } from '../../../utils.js' -import { createUser } from '../../utils.js' +import { createAuthWithUser } from '../../utils.js' import { ensureValidUsername, ensurePasswordIsPresent, @@ -14,7 +14,7 @@ export default handleRejection(async (req, res) => { const additionalFields = await validateAndGetAdditionalFields(userFields) - await createUser( + await createAuthWithUser( { username: userFields.username, password: userFields.password, diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index e835a9346a..bd13a4bc76 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -34,11 +34,11 @@ export const authConfig = { successRedirectPath: "{= successRedirectPath =}", } -export async function findAuthWithUserBy(where: Prisma.{= authEntityUpper =}WhereUniqueInput) { - return prisma.{= authEntityLower =}.findUnique({ where, include: { {= userFieldOnAuthEntityName =}: true }}); +export async function findAuthWithUserBy(where: Prisma.{= authEntityUpper =}WhereInput) { + return prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }}); } -export async function createUser(data: Prisma.{= authEntityUpper =}CreateInput, additionalFields: PossibleAdditionalSignupFields) { +export async function createAuthWithUser(data: Prisma.{= authEntityUpper =}CreateInput, additionalFields: PossibleAdditionalSignupFields) { try { return await prisma.{= authEntityLower =}.create({ data: { diff --git a/waspc/examples/auth-model-experiment/main.wasp b/waspc/examples/auth-model-experiment/main.wasp index 73cf226e12..0ce42fcc88 100644 --- a/waspc/examples/auth-model-experiment/main.wasp +++ b/waspc/examples/auth-model-experiment/main.wasp @@ -6,10 +6,29 @@ app authModelExperiment { auth: { userEntity: User, methods: { - usernameAndPassword: {} + // usernameAndPassword: {}, + email: { + fromField: { + name: "ToDO App", + email: "mihovil@ilakovac.com" + }, + emailVerification: { + clientRoute: EmailVerificationRoute, + }, + passwordReset: { + clientRoute: PasswordResetRoute + }, + allowUnverifiedLogin: false, + }, }, onAuthFailedRedirectTo: "/" - } + }, + emailSender: { + provider: SMTP, + defaultFrom: { + email: "mihovil@ilakovac.com" + }, + }, } route RootRoute { path: "/", to: MainPage } @@ -29,4 +48,19 @@ page Login { route SignupRoute { path: "/signup", to: Signup } page Signup { component: import { Signup } from "@client/auth.jsx" +} + +route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage } +page PasswordResetPage { + component: import { PasswordReset } from "@client/auth.jsx", +} + +route EmailVerificationRoute { path: "/email-verification-", to: EmailVerificationPage } +page EmailVerificationPage { + component: import { EmailVerification } from "@client/auth.jsx", +} + +route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordReset } +page RequestPasswordReset { + component: import { RequestPasswordReset } from "@client/auth.jsx" } \ No newline at end of file diff --git a/waspc/examples/auth-model-experiment/migrations/20231115151928_initial/migration.sql b/waspc/examples/auth-model-experiment/migrations/20231115160416_initial/migration.sql similarity index 62% rename from waspc/examples/auth-model-experiment/migrations/20231115151928_initial/migration.sql rename to waspc/examples/auth-model-experiment/migrations/20231115160416_initial/migration.sql index 6d31ce0230..6a468a493d 100644 --- a/waspc/examples/auth-model-experiment/migrations/20231115151928_initial/migration.sql +++ b/waspc/examples/auth-model-experiment/migrations/20231115160416_initial/migration.sql @@ -6,12 +6,19 @@ CREATE TABLE "User" ( -- CreateTable CREATE TABLE "Auth" ( "id" TEXT NOT NULL PRIMARY KEY, - "username" TEXT NOT NULL, - "password" TEXT NOT NULL, + "email" TEXT, + "username" TEXT, + "password" TEXT, + "isEmailVerified" BOOLEAN NOT NULL DEFAULT false, + "emailVerificationSentAt" DATETIME, + "passwordResetSentAt" DATETIME, "userId" TEXT, CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- CreateIndex +CREATE UNIQUE INDEX "Auth_email_key" ON "Auth"("email"); + -- CreateIndex CREATE UNIQUE INDEX "Auth_username_key" ON "Auth"("username"); diff --git a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx index fe8c26e65c..4794e15402 100644 --- a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx +++ b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx @@ -24,7 +24,7 @@ const MainPage = () => {

{" "} - You are logged in as {user.auth.username}!{" "} + You are logged in as {user.auth.email}!{" "}

{" "} diff --git a/waspc/examples/auth-model-experiment/src/client/auth.jsx b/waspc/examples/auth-model-experiment/src/client/auth.jsx index a2087045aa..5775ba1240 100644 --- a/waspc/examples/auth-model-experiment/src/client/auth.jsx +++ b/waspc/examples/auth-model-experiment/src/client/auth.jsx @@ -1,5 +1,8 @@ import { SignupForm } from "@wasp/auth/forms/Signup"; import { LoginForm } from "@wasp/auth/forms/Login"; +import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; +import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; +import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; export function Signup() { return ; @@ -8,3 +11,15 @@ export function Signup() { export function Login() { return ; } + +export function RequestPasswordReset() { + return ; +} + +export function PasswordReset() { + return ; +} + +export function EmailVerification() { + return ; +} diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index ba4f5a5113..04c71eb252 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -53,8 +53,6 @@ validateAppSpec spec = [ validateWasp spec, validateAppAuthIsSetIfAnyPageRequiresAuth spec, validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec, - validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec, - validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed spec, validateEmailSenderIsDefinedIfEmailAuthIsUsed spec, validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec, validateDbIsPostgresIfPgBossUsed spec, @@ -146,34 +144,6 @@ validateDbIsPostgresIfPgBossUsed spec = | isPgBossJobExecutorUsed spec && not (isPostgresUsed spec) ] -validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed :: AppSpec -> [ValidationError] -validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec = case App.auth (snd $ getApp spec) of - Nothing -> [] - Just auth -> - if not $ Auth.isUsernameAndPasswordAuthEnabled auth - then [] - else validationErrors - where - validationErrors = [] - -validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed :: AppSpec -> [ValidationError] -validateAuthUserEntityHasCorrectFieldsIfEmailAuthIsUsed spec = case App.auth (snd $ getApp spec) of - Nothing -> [] - Just auth -> - if not $ Auth.isEmailAuthEnabled auth - then [] - else - let (userEntityName, userEntity) = AS.resolveRef spec (Auth.userEntity auth) - userEntityFields = Entity.getFields userEntity - in concatMap - (validateEntityHasField userEntityName "app.auth.userEntity" userEntityFields) - [ ("email", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.String), "String"), - ("password", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.String), "String"), - ("isEmailVerified", Entity.Field.FieldTypeScalar Entity.Field.Boolean, "Boolean"), - ("emailVerificationSentAt", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.DateTime), "DateTime?"), - ("passwordResetSentAt", Entity.Field.FieldTypeComposite (Entity.Field.Optional Entity.Field.DateTime), "DateTime?") - ] - validateEmailSenderIsDefinedIfEmailAuthIsUsed :: AppSpec -> [ValidationError] validateEmailSenderIsDefinedIfEmailAuthIsUsed spec = case App.auth app of Nothing -> [] diff --git a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs index d9e3be9803..53efb73829 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs @@ -43,13 +43,19 @@ makeAuthEntity userEntityIdType userEntityName = case parsePslBody authEntityPsl Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err Right pslBody -> return [(authEntityName, AS.Entity.makeEntity pslBody)] where + -- TODO(miho): decide if we want to switch between fields for username and email + -- based auth. It's much simpler to just have everything and let some fields be null. authEntityPslBody = T.unpack [trimming| id String @id @default(uuid()) - username String @unique - password String - userId ${userEntityIdTypeText}? @unique + email String? @unique + username String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? + userId ${userEntityIdTypeText}? @unique ${userFieldOnAuthEntityNameText} ${userEntityNameText}? @relation(fields: [userId], references: [id], onDelete: Cascade) |] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs index 23fc4e5db5..dd6fb9d00c 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs @@ -25,6 +25,7 @@ import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender import Wasp.AppSpec.Util (getRoutePathFromRef) import Wasp.Generator.AuthProviders (emailAuthProvider) import qualified Wasp.Generator.AuthProviders.Email as Email +import qualified Wasp.Generator.DbGenerator.Auth as DbAuth import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator.Common as C @@ -113,5 +114,7 @@ genUtils auth = return $ C.mkTmplFdWithData tmplFile (Just tmplData) tmplData = object [ "userEntityUpper" .= (userEntityName :: String), - "userEntityLower" .= (Util.toLowerFirst userEntityName :: String) + "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), + "authEntityUpper" .= (DbAuth.authEntityName :: String), + "authEntityLower" .= (Util.toLowerFirst DbAuth.authEntityName :: String) ] From 238c3a875b98b5fe58ca5174f4a39618928a3e13 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 15 Nov 2023 22:39:01 +0100 Subject: [PATCH 04/62] Add support for Social auth --- .../src/auth/providers/oauth/createRouter.ts | 46 +++++++++++-------- .../server/src/auth/providers/oauth/types.ts | 2 +- .../examples/auth-model-experiment/main.wasp | 33 +++++++------ .../migration.sql | 9 ++++ .../migration.sql | 2 + .../src/client/MainPage.jsx | 9 +++- .../auth-model-experiment/src/client/auth.jsx | 12 ++--- .../src/server/google.ts | 17 +++++++ waspc/src/Wasp/AppSpec/Valid.hs | 43 ----------------- waspc/src/Wasp/Generator/DbGenerator/Auth.hs | 44 ++++++++++++++++-- .../ServerGenerator/Auth/OAuthAuthG.hs | 19 ++++---- 11 files changed, 138 insertions(+), 98 deletions(-) rename waspc/examples/auth-model-experiment/migrations/{20231115160416_initial => 20231115210800_initial}/migration.sql (69%) create mode 100644 waspc/examples/auth-model-experiment/migrations/20231115211357_add_email_to_user/migration.sql create mode 100644 waspc/examples/auth-model-experiment/src/server/google.ts 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 aafb489ac5..9d3a915bac 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 @@ -2,14 +2,18 @@ import { Router } from "express" import passport from "passport" -import { v4 as uuidv4 } from 'uuid' import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js' import { sign } from '../../../core/auth.js' -import { authConfig, contextWithUserEntity, createUser } from "../../utils.js" +import { + authConfig, + contextWithUserEntity, + createAuthWithUser, + findAuthWithUserBy, +} from "../../utils.js" -import type { {= userEntityUpper =} } from '../../../entities'; +import type { {= authEntityUpper =} } from '@prisma/client' import type { ProviderConfig, RequestWithWasp } from "../types.js" import type { GetUserFieldsFn } from "./types.js" import { handleRejection } from "../../../utils.js" @@ -46,9 +50,9 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }); // TODO: In the future we could make this configurable, possibly associating an external account // with the currently logged in account, or by some DB lookup. - const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields); + const auth = await findOrCreateAuthByAuthProvider(provider.id, providerProfile.id, getUserFields); - const token = await sign(user.id); + const token = await sign(auth.{= userFieldOnAuthEntityName =}.id); res.json({ token }); }) ) @@ -56,34 +60,38 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat return router; } -async function findOrCreateUserByExternalAuthAssociation( +async function findOrCreateAuthByAuthProvider( provider: string, providerId: string, getUserFields: () => ReturnType, -): Promise<{= userEntityUpper =}> { +) { // Attempt to find a User by an external auth association. - const externalAuthAssociation = await prisma.{= externalAuthEntityLower =}.findFirst({ + const authProvider = await prisma.{= authProviderEntityLower =}.findFirst({ where: { provider, providerId }, - include: { user: true } + include: { + {= authFieldOnProviderEntityName =}: { + include: { + {= userFieldOnAuthEntityName =}: true + } + } + } }) - if (externalAuthAssociation) { - return externalAuthAssociation.user + if (authProvider) { + return authProvider.{= authFieldOnProviderEntityName =} } // No external auth association linkage found. Create a new User using details from // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User. const userFields = await getUserFields() - const userAndExternalAuthAssociation = { - ...userFields, - {=# isPasswordOnUserEntity =} - // TODO: Decouple social from usernameAndPassword auth. - password: uuidv4(), - {=/ isPasswordOnUserEntity =} - externalAuthAssociations: { + const authAndProviderData = { + {= providersFieldOnAuthEntityName =}: { create: [{ provider, providerId }] } } - return createUser(userAndExternalAuthAssociation) + // TODO(miho): decide if we want to keep the custom data on User or Auth? + const auth = await createAuthWithUser(authAndProviderData, userFields) + // NOTE: we are fetching the auth again becuase it incldues nested user + return findAuthWithUserBy({ id: auth.id }); } 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 266c289247..b0e0fd614c 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 @@ -9,7 +9,7 @@ export type OAuthConfig = { scope?: string[]; } -export type CreateOAuthUser = Omit +export type CreateOAuthUser = Prisma.{= userEntityName =}CreateInput export type UserDefinedConfigFn = () => { [key: string]: any } diff --git a/waspc/examples/auth-model-experiment/main.wasp b/waspc/examples/auth-model-experiment/main.wasp index 0ce42fcc88..1e531430af 100644 --- a/waspc/examples/auth-model-experiment/main.wasp +++ b/waspc/examples/auth-model-experiment/main.wasp @@ -6,20 +6,24 @@ app authModelExperiment { auth: { userEntity: User, methods: { - // usernameAndPassword: {}, - email: { - fromField: { - name: "ToDO App", - email: "mihovil@ilakovac.com" - }, - emailVerification: { - clientRoute: EmailVerificationRoute, - }, - passwordReset: { - clientRoute: PasswordResetRoute - }, - allowUnverifiedLogin: false, + google: { + getUserFieldsFn: import { getUserFields } from "@server/google.js", + configFn: import { getGoogleConfig } from "@server/google.js", }, + usernameAndPassword: {}, + // email: { + // fromField: { + // name: "ToDO App", + // email: "mihovil@ilakovac.com" + // }, + // emailVerification: { + // clientRoute: EmailVerificationRoute, + // }, + // passwordReset: { + // clientRoute: PasswordResetRoute + // }, + // allowUnverifiedLogin: false, + // }, }, onAuthFailedRedirectTo: "/" }, @@ -38,6 +42,9 @@ page MainPage { entity User {=psl id String @id @default(uuid()) + // TODO: figure out how to pass data from all auth methods + // We have now - additional signup fields + Social providers setup + email String? psl=} route LoginRoute { path: "/login", to: Login } diff --git a/waspc/examples/auth-model-experiment/migrations/20231115160416_initial/migration.sql b/waspc/examples/auth-model-experiment/migrations/20231115210800_initial/migration.sql similarity index 69% rename from waspc/examples/auth-model-experiment/migrations/20231115160416_initial/migration.sql rename to waspc/examples/auth-model-experiment/migrations/20231115210800_initial/migration.sql index 6a468a493d..8077f9c795 100644 --- a/waspc/examples/auth-model-experiment/migrations/20231115160416_initial/migration.sql +++ b/waspc/examples/auth-model-experiment/migrations/20231115210800_initial/migration.sql @@ -16,6 +16,15 @@ CREATE TABLE "Auth" ( CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- CreateTable +CREATE TABLE "SocialAuthProvider" ( + "id" TEXT NOT NULL PRIMARY KEY, + "provider" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "authId" TEXT NOT NULL, + CONSTRAINT "SocialAuthProvider_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + -- CreateIndex CREATE UNIQUE INDEX "Auth_email_key" ON "Auth"("email"); diff --git a/waspc/examples/auth-model-experiment/migrations/20231115211357_add_email_to_user/migration.sql b/waspc/examples/auth-model-experiment/migrations/20231115211357_add_email_to_user/migration.sql new file mode 100644 index 0000000000..10eb0d03d3 --- /dev/null +++ b/waspc/examples/auth-model-experiment/migrations/20231115211357_add_email_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "email" TEXT; diff --git a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx index 4794e15402..f0c56643de 100644 --- a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx +++ b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx @@ -22,9 +22,14 @@ const MainPage = () => { {user && (

-

+

{" "} - You are logged in as {user.auth.email}!{" "} + You are logged in as{" "} + {JSON.stringify(user, null, 2)}!{" "}

{" "} diff --git a/waspc/examples/auth-model-experiment/src/client/auth.jsx b/waspc/examples/auth-model-experiment/src/client/auth.jsx index 5775ba1240..ee1962a397 100644 --- a/waspc/examples/auth-model-experiment/src/client/auth.jsx +++ b/waspc/examples/auth-model-experiment/src/client/auth.jsx @@ -1,8 +1,8 @@ import { SignupForm } from "@wasp/auth/forms/Signup"; import { LoginForm } from "@wasp/auth/forms/Login"; -import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; -import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; -import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; +// import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; +// import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; +// import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; export function Signup() { return ; @@ -13,13 +13,13 @@ export function Login() { } export function RequestPasswordReset() { - return ; + return

; } export function PasswordReset() { - return ; + return
; } export function EmailVerification() { - return ; + return
; } diff --git a/waspc/examples/auth-model-experiment/src/server/google.ts b/waspc/examples/auth-model-experiment/src/server/google.ts new file mode 100644 index 0000000000..6f1654808e --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/server/google.ts @@ -0,0 +1,17 @@ +import { GetUserFieldsFn } from "@wasp/types"; + +export const getUserFields: GetUserFieldsFn = async (_context, data) => { + console.log(data); + + return { + email: data.profile.emails[0].value, + }; +}; + +export const getGoogleConfig = () => { + return { + clientID: process.env["GOOGLE_CLIENT_ID"], + clientSecret: process.env["GOOGLE_CLIENT_SECRET"], + scope: ["profile", "email"], + }; +}; diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index 04c71eb252..2e3b11518a 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -54,7 +54,6 @@ validateAppSpec spec = validateAppAuthIsSetIfAnyPageRequiresAuth spec, validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec, validateEmailSenderIsDefinedIfEmailAuthIsUsed spec, - validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec, validateDbIsPostgresIfPgBossUsed spec, validateApiRoutesAreUnique spec, validateApiNamespacePathsAreUnique spec, @@ -156,48 +155,6 @@ validateEmailSenderIsDefinedIfEmailAuthIsUsed spec = case App.auth app of where app = snd $ getApp spec -validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed :: AppSpec -> [ValidationError] -validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec = case App.auth (snd $ getApp spec) of - Nothing -> [] - Just auth -> - if not $ Auth.isExternalAuthEnabled auth - then [] - else case Auth.externalAuthEntity auth of - Nothing -> [GenericValidationError "app.auth.externalAuthEntity must be specified when using a social login method."] - Just externalAuthEntityRef -> - let (userEntityName, userEntity) = AS.resolveRef spec (Auth.userEntity auth) - userEntityFields = Entity.getFields userEntity - (externalAuthEntityName, externalAuthEntity) = AS.resolveRef spec externalAuthEntityRef - externalAuthEntityFields = Entity.getFields externalAuthEntity - externalAuthEntityValidationErrors = - concatMap - (validateEntityHasField externalAuthEntityName "app.auth.externalAuthEntity" externalAuthEntityFields) - [ ("provider", Entity.Field.FieldTypeScalar Entity.Field.String, "String"), - ("providerId", Entity.Field.FieldTypeScalar Entity.Field.String, "String"), - ("user", Entity.Field.FieldTypeScalar (Entity.Field.UserType userEntityName), userEntityName), - ("userId", Entity.Field.FieldTypeScalar Entity.Field.Int, "Int") - ] - userEntityValidationErrors = - concatMap - (validateEntityHasField userEntityName "app.auth.userEntity" userEntityFields) - [ ( "externalAuthAssociations", - Entity.Field.FieldTypeComposite $ Entity.Field.List $ Entity.Field.UserType externalAuthEntityName, - externalAuthEntityName ++ "[]" - ) - ] - in externalAuthEntityValidationErrors ++ userEntityValidationErrors - -validateEntityHasField :: String -> String -> [Entity.Field.Field] -> (String, Entity.Field.FieldType, String) -> [ValidationError] -validateEntityHasField entityName authEntityPath entityFields (fieldName, fieldType, fieldTypeName) = - let maybeField = findFieldByName fieldName entityFields - in case maybeField of - Just providerField - | Entity.Field.fieldType providerField == fieldType -> [] - _ -> - [ GenericValidationError $ - "Entity '" ++ entityName ++ "' (referenced by " ++ authEntityPath ++ ") must have field '" ++ fieldName ++ "' of type '" ++ fieldTypeName ++ "'." - ] - validateApiRoutesAreUnique :: AppSpec -> [ValidationError] validateApiRoutesAreUnique spec = if null groupsOfConflictingRoutes diff --git a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs index 53efb73829..016875c5a1 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs @@ -17,18 +17,31 @@ import qualified Wasp.Psl.Ast.Model as Psl.Model.Field authEntityName :: String authEntityName = "Auth" +authEntityIdType :: String +authEntityIdType = "String" + userFieldOnAuthEntityName :: String userFieldOnAuthEntityName = "user" authFieldOnUserEntityName :: String authFieldOnUserEntityName = "auth" +authFieldOnProviderEntityName :: String +authFieldOnProviderEntityName = "auth" + +providerEntityName :: String +providerEntityName = "SocialAuthProvider" + +providersFieldOnAuthEntityName :: String +providersFieldOnAuthEntityName = "providers" + injectAuth :: Maybe (String, AS.Entity.Entity) -> [(String, AS.Entity.Entity)] -> Generator [(String, AS.Entity.Entity)] injectAuth Nothing entities = return entities injectAuth (Just (userEntityName, userEntity)) entities = do userEntityIdType <- getUserEntityId userEntity authEntity <- makeAuthEntity userEntityIdType userEntityName - return $ injectAuthIntoUserEntity userEntityName $ entities ++ authEntity + providerEntity <- makeProviderEntity + return $ injectAuthIntoUserEntity userEntityName $ entities ++ [authEntity, providerEntity] getUserEntityId :: AS.Entity.Entity -> Generator String getUserEntityId entity = @@ -38,17 +51,36 @@ getUserEntityId entity = Just idType -> return idType ) -makeAuthEntity :: String -> String -> Generator [(String, AS.Entity.Entity)] +makeProviderEntity :: Generator (String, AS.Entity.Entity) +makeProviderEntity = case parsePslBody providerEntityPslBody of + Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Provider entity: " ++ show err + Right pslBody -> return (providerEntityName, AS.Entity.makeEntity pslBody) + where + providerEntityPslBody = + T.unpack + [trimming| + id String @id @default(uuid()) + provider String + providerId String + authId ${authEntityIdTypeText} + ${authFieldOnProviderEntityNameText} ${authEntityNameText} @relation(fields: [authId], references: [id], onDelete: Cascade) + |] + + authEntityIdTypeText = T.pack authEntityIdType + authEntityNameText = T.pack authEntityName + authFieldOnProviderEntityNameText = T.pack authFieldOnProviderEntityName + +makeAuthEntity :: String -> String -> Generator (String, AS.Entity.Entity) makeAuthEntity userEntityIdType userEntityName = case parsePslBody authEntityPslBody of Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err - Right pslBody -> return [(authEntityName, AS.Entity.makeEntity pslBody)] + Right pslBody -> return (authEntityName, AS.Entity.makeEntity pslBody) where -- TODO(miho): decide if we want to switch between fields for username and email -- based auth. It's much simpler to just have everything and let some fields be null. authEntityPslBody = T.unpack [trimming| - id String @id @default(uuid()) + id ${authEntityIdTypeText} @id @default(uuid()) email String? @unique username String? @unique password String? @@ -57,11 +89,15 @@ makeAuthEntity userEntityIdType userEntityName = case parsePslBody authEntityPsl passwordResetSentAt DateTime? userId ${userEntityIdTypeText}? @unique ${userFieldOnAuthEntityNameText} ${userEntityNameText}? @relation(fields: [userId], references: [id], onDelete: Cascade) + ${providersFieldOnAuthEntityNameText} ${providerEntityNameText}[] |] + authEntityIdTypeText = T.pack authEntityIdType userEntityIdTypeText = T.pack userEntityIdType userEntityNameText = T.pack userEntityName userFieldOnAuthEntityNameText = T.pack userFieldOnAuthEntityName + providerEntityNameText = T.pack providerEntityName + providersFieldOnAuthEntityNameText = T.pack providersFieldOnAuthEntityName injectAuthIntoUserEntity :: String -> [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)] injectAuthIntoUserEntity userEntityName entities = diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs index f74f7a6c63..cc89226e9e 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs @@ -30,6 +30,7 @@ import Wasp.AppSpec.Valid (doesUserEntityContainField, getApp) import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider) import Wasp.Generator.AuthProviders.OAuth (OAuthAuthProvider) import qualified Wasp.Generator.AuthProviders.OAuth as OAuth +import qualified Wasp.Generator.DbGenerator.Auth as DbAuth import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import Wasp.Generator.ServerGenerator.Common (ServerSrcDir) @@ -49,25 +50,23 @@ genOAuthAuth spec auth genOAuthHelpers :: AS.AppSpec -> AS.Auth.Auth -> Generator [FileDraft] genOAuthHelpers spec auth = sequence - [ genCreateRouter spec auth, + [ genCreateRouter, genTypes auth, genDefaults spec, return $ C.mkSrcTmplFd [relfile|auth/providers/oauth/init.ts|] ] -genCreateRouter :: AS.AppSpec -> AS.Auth.Auth -> Generator FileDraft -genCreateRouter spec auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers/oauth/createRouter.ts|] (Just tmplData) +genCreateRouter :: Generator FileDraft +genCreateRouter = return $ C.mkTmplFdWithData [relfile|src/auth/providers/oauth/createRouter.ts|] (Just tmplData) where tmplData = object - [ "userEntityUpper" .= (userEntityName :: String), - "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), - "externalAuthEntityLower" .= (Util.toLowerFirst externalAuthEntityName :: String), - "isPasswordOnUserEntity" .= isPasswordOnUserEntity + [ "authEntityUpper" .= (DbAuth.authEntityName :: String), + "authProviderEntityLower" .= (Util.toLowerFirst DbAuth.providerEntityName :: String), + "providersFieldOnAuthEntityName" .= (DbAuth.providersFieldOnAuthEntityName :: String), + "authFieldOnProviderEntityName" .= (DbAuth.authFieldOnProviderEntityName :: String), + "userFieldOnAuthEntityName" .= (DbAuth.userFieldOnAuthEntityName :: String) ] - userEntityName = AS.refName $ AS.Auth.userEntity auth - externalAuthEntityName = maybe "undefined" AS.refName (AS.Auth.externalAuthEntity auth) - isPasswordOnUserEntity = doesUserEntityContainField spec "password" == Just True genTypes :: AS.Auth.Auth -> Generator FileDraft genTypes auth = return $ C.mkTmplFdWithData tmplFile (Just tmplData) From 627ecf9dccb2b86aa00840678158eed1132a9fcc Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Thu, 16 Nov 2023 15:52:34 +0100 Subject: [PATCH 05/62] Add tasks to User --- .../templates/server/src/core/auth.js | 7 +- .../examples/auth-model-experiment/main.wasp | 42 +++++- .../20231116085207_add_tasks/migration.sql | 7 + .../src/client/DetailPage.tsx | 37 +++++ .../auth-model-experiment/src/client/Main.css | 15 ++ .../src/client/MainPage.jsx | 54 ------- .../src/client/MainPage.tsx | 139 ++++++++++++++++++ .../auth-model-experiment/src/server/tasks.ts | 62 ++++++++ .../Wasp/Generator/ServerGenerator/AuthG.hs | 3 +- 9 files changed, 304 insertions(+), 62 deletions(-) create mode 100644 waspc/examples/auth-model-experiment/migrations/20231116085207_add_tasks/migration.sql create mode 100644 waspc/examples/auth-model-experiment/src/client/DetailPage.tsx delete mode 100644 waspc/examples/auth-model-experiment/src/client/MainPage.jsx create mode 100644 waspc/examples/auth-model-experiment/src/client/MainPage.tsx create mode 100644 waspc/examples/auth-model-experiment/src/server/tasks.ts diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js index de76731140..5923c87a6e 100644 --- a/waspc/data/Generator/templates/server/src/core/auth.js +++ b/waspc/data/Generator/templates/server/src/core/auth.js @@ -48,11 +48,16 @@ export async function getUserFromToken(token) { } } + // TODO: this might not be the best thing to do, each request makes a db call const user = await prisma.{= userEntityLower =} .findUnique({ where: { id: userIdFromToken }, include: { - {= authFieldOnUserEntityName =}: true + {= authFieldOnUserEntityName =}: { + include: { + {= providersFieldOnAuthEntityName =}: true + } + } } }) if (!user) { diff --git a/waspc/examples/auth-model-experiment/main.wasp b/waspc/examples/auth-model-experiment/main.wasp index 1e531430af..602baf5355 100644 --- a/waspc/examples/auth-model-experiment/main.wasp +++ b/waspc/examples/auth-model-experiment/main.wasp @@ -25,7 +25,7 @@ app authModelExperiment { // allowUnverifiedLogin: false, // }, }, - onAuthFailedRedirectTo: "/" + onAuthFailedRedirectTo: "/login" }, emailSender: { provider: SMTP, @@ -35,18 +35,42 @@ app authModelExperiment { }, } -route RootRoute { path: "/", to: MainPage } -page MainPage { - component: import Main from "@client/MainPage.jsx" -} - entity User {=psl id String @id @default(uuid()) // TODO: figure out how to pass data from all auth methods // We have now - additional signup fields + Social providers setup + // The data goes to the User (custom stuff) email String? + tasks Task[] psl=} +entity Task {=psl + id Int @id @default(autoincrement()) + title String + userId String + user User @relation(fields: [userId], references: [id]) +psl=} + +crud tasks { + entity: Task, + operations: { + get: {}, + getAll: { + overrideFn: import { getAllTasks } from "@server/tasks.js", + }, + create: { + overrideFn: import { createTask } from "@server/tasks.js", + }, + update: {}, + delete: {}, + }, +} + +route RootRoute { path: "/", to: MainPage } +page MainPage { + component: import Main from "@client/MainPage.tsx" +} + route LoginRoute { path: "/login", to: Login } page Login { component: import { Login } from "@client/auth.jsx" @@ -70,4 +94,10 @@ page EmailVerificationPage { route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordReset } page RequestPasswordReset { component: import { RequestPasswordReset } from "@client/auth.jsx" +} + +route DetailRoute { path: "/:id/:something?", to: DetailPage } +page DetailPage { + component: import Main from "@client/DetailPage.tsx", + authRequired: true, } \ No newline at end of file diff --git a/waspc/examples/auth-model-experiment/migrations/20231116085207_add_tasks/migration.sql b/waspc/examples/auth-model-experiment/migrations/20231116085207_add_tasks/migration.sql new file mode 100644 index 0000000000..369b2bbb93 --- /dev/null +++ b/waspc/examples/auth-model-experiment/migrations/20231116085207_add_tasks/migration.sql @@ -0,0 +1,7 @@ +-- CreateTable +CREATE TABLE "Task" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/waspc/examples/auth-model-experiment/src/client/DetailPage.tsx b/waspc/examples/auth-model-experiment/src/client/DetailPage.tsx new file mode 100644 index 0000000000..6e88c1bf84 --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/DetailPage.tsx @@ -0,0 +1,37 @@ +import "./Main.css"; + +import React from "react"; +import { useParams } from "react-router-dom"; +import { Link } from "@wasp/router"; + +import { tasks as tasksCrud } from "@wasp/crud/tasks"; + +const DetailPage = () => { + const { id } = useParams<{ id: string }>(); + const { data: task, isLoading } = tasksCrud.get.useQuery({ + id: parseInt(id, 10), + }); + + return ( +
+
+

Tasks master

+
+ {isLoading &&
Loading...
} + {task && ( +
+ <> +
+ {JSON.stringify(task, null, 2)} +
+ +
+ )} +
+ Return +
+
+ ); +}; + +export default DetailPage; diff --git a/waspc/examples/auth-model-experiment/src/client/Main.css b/waspc/examples/auth-model-experiment/src/client/Main.css index b6e7ed3f13..1ebe9d667f 100644 --- a/waspc/examples/auth-model-experiment/src/client/Main.css +++ b/waspc/examples/auth-model-experiment/src/client/Main.css @@ -87,3 +87,18 @@ code { font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace; } +.task__title { + padding: 1rem; +} + +button, +input, +.button { + padding: 0.5rem 1rem; + background: yellow; + border: 1px solid #000; + font-size: 1rem; + border-radius: 5px; + font-weight: 400; + display: inline-block; +} diff --git a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx b/waspc/examples/auth-model-experiment/src/client/MainPage.jsx deleted file mode 100644 index f0c56643de..0000000000 --- a/waspc/examples/auth-model-experiment/src/client/MainPage.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import waspLogo from "./waspLogo.png"; -import "./Main.css"; - -import logout from "@wasp/auth/logout"; -import useAuth from "@wasp/auth/useAuth"; - -import { Link } from "@wasp/router"; - -const MainPage = () => { - const { data: user } = useAuth(); - return ( -
-
-
- wasp -
- -

- {" "} - Welcome to Wasp - you just started a new app!{" "} -

- - {user && ( -
-

- {" "} - You are logged in as{" "} - {JSON.stringify(user, null, 2)}!{" "} -

-

- {" "} - Your user id is {user.id}.{" "} -

-
- )} - - {user && ( - - )} - - - Login - -
-
- ); -}; -export default MainPage; diff --git a/waspc/examples/auth-model-experiment/src/client/MainPage.tsx b/waspc/examples/auth-model-experiment/src/client/MainPage.tsx new file mode 100644 index 0000000000..ea87328ec9 --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/client/MainPage.tsx @@ -0,0 +1,139 @@ +import "./Main.css"; + +import React, { useState } from "react"; +import { Link, routes } from "@wasp/router"; +import logout from "@wasp/auth/logout"; + +import { tasks as tasksCrud } from "@wasp/crud/tasks"; +import { User } from "@wasp/entities"; +import useAuth from "@wasp/auth/useAuth"; + +const MainPage = ({ user }: { user: User }) => { + const { data: auth } = useAuth(); + const { data: tasks, isLoading } = tasksCrud.getAll.useQuery(); + + const createTask = tasksCrud.create.useAction(); + const deleteTask = tasksCrud.delete.useAction(); + const updateTask = tasksCrud.update.useAction(); + + const [newTaskTitle, setNewTaskTitle] = useState(""); + const [editTaskTitle, setEditTaskTitle] = useState(""); + const [error, setError] = useState(""); + const [isEditing, setIsEditing] = useState(null); + + async function handleCreateTask(e: React.FormEvent) { + setError(""); + e.preventDefault(); + try { + await createTask({ + title: newTaskTitle, + }); + } catch (err: unknown) { + setError(`Error creating task: ${err as any}`); + } + setNewTaskTitle(""); + } + + async function handleUpdateTask(e: React.FormEvent) { + setError(""); + e.preventDefault(); + try { + await updateTask({ id: isEditing!, title: editTaskTitle }); + } catch (err: unknown) { + setError("Error updating task."); + } + setIsEditing(null); + setEditTaskTitle(""); + } + + function handleStartEditing(task: { id: number; title: string }) { + setIsEditing(task.id); + setEditTaskTitle(task.title); + } + + async function handleTaskDelete(task: { id: number }) { + try { + if (!confirm("Are you sure you want to delete this task?")) { + return; + } + await deleteTask({ id: task.id }); + } catch (err: unknown) { + setError("Error deleting task."); + } + } + + return ( +
+
+

Tasks master

+ {auth &&
{JSON.stringify(auth, null, 2)}
} + {!auth && ( +
+            Not logged in. Login
+          
+ )} +
{error}
+
+ {isLoading &&
Loading...
} + {tasks?.map((task) => ( +
+ {task.id === isEditing ? ( + <> +
+ + setEditTaskTitle(e.target.value)} + /> + + +
+ + ) : ( + <> +
+ + {task.title} + {" "} + ({task.user.email}) +
+ + handleStartEditing(task)} + className="button" + > + Edit + + + )} +
+ ))} + {tasks?.length === 0 &&
No tasks yet.
} +
+
+ + setNewTaskTitle(e.target.value)} + /> + + +
+ +
+
+ ); +}; + +export default MainPage; diff --git a/waspc/examples/auth-model-experiment/src/server/tasks.ts b/waspc/examples/auth-model-experiment/src/server/tasks.ts new file mode 100644 index 0000000000..52cd059c9f --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/server/tasks.ts @@ -0,0 +1,62 @@ +import HttpError from "@wasp/core/HttpError.js"; +import type { GetQuery, GetAllQuery, CreateAction } from "@wasp/crud/tasks"; +import { Task, User } from "@wasp/entities"; +import { Auth } from "@prisma/client"; + +export const getTask = (async (args, context) => { + return context.entities.Task.findUnique({ + where: { id: args.id }, + include: { + user: { + include: { + auth: { + select: { + username: true, + }, + }, + }, + }, + }, + }); +}) satisfies GetQuery< + { id: Task["id"] }, + { user: User & { auth: Pick | null } } | null +>; + +export const getAllTasks = (async (args, context) => { + return context.entities.Task.findMany({ + orderBy: { id: "desc" }, + select: { + id: true, + title: true, + user: { + include: { + auth: { + select: { + username: true, + }, + }, + }, + }, + }, + }); +}) satisfies GetAllQuery<{}, {}>; + +export const createTask = (async (args, context) => { + if (!context.user) { + throw new HttpError(401, "You must be logged in to create a task."); + } + if (!args.title) { + throw new HttpError(400, "Task title is required."); + } + return context.entities.Task.create({ + data: { + title: args.title!, + user: { + connect: { + id: context.user.id, + }, + }, + }, + }); +}) satisfies CreateAction<{ title: Task["title"] }, Task>; diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index b1d3d73598..606d0e37fd 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -70,7 +70,8 @@ genCoreAuth auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmpl in object [ "userEntityUpper" .= (userEntityName :: String), "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), - "authFieldOnUserEntityName" .= (DbAuth.authFieldOnUserEntityName :: String) + "authFieldOnUserEntityName" .= (DbAuth.authFieldOnUserEntityName :: String), + "providersFieldOnAuthEntityName" .= (DbAuth.providersFieldOnAuthEntityName :: String) ] genAuthMiddleware :: Generator FileDraft From 4259689085696fd5a244d2b9c3a7e9842f1d0c15 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 24 Nov 2023 15:39:16 +0100 Subject: [PATCH 06/62] Update authentication providers and entities --- .../templates/react-app/src/entities/index.ts | 3 ++ .../templates/server/src/_types/index.ts | 3 +- .../templates/server/src/auth/index.ts | 8 +---- .../src/auth/providers/config/_oauth.ts | 2 +- .../server/src/auth/providers/email/signup.ts | 5 ++- .../server/src/auth/providers/email/types.ts | 4 --- .../server/src/auth/providers/email/utils.ts | 25 +++++++++------ .../server/src/auth/providers/local/types.ts | 3 -- .../src/auth/providers/oauth/createRouter.ts | 5 ++- .../src/auth/providers/oauth/defaults.ts | 12 ------- .../server/src/auth/providers/oauth/init.ts | 2 +- .../server/src/auth/providers/types.ts | 14 ++++---- .../templates/server/src/auth/utils.ts | 24 ++++++++------ .../templates/server/src/crud/_operations.ts | 4 +-- .../templates/server/src/entities/index.ts | 3 ++ .../examples/auth-model-experiment/main.wasp | 32 +++++++++---------- waspc/src/Wasp/Generator/ServerGenerator.hs | 8 ++++- .../ServerGenerator/Auth/EmailAuthG.hs | 3 +- .../ServerGenerator/Auth/LocalAuthG.hs | 5 +-- .../ServerGenerator/Auth/OAuthAuthG.hs | 20 ++++-------- .../Wasp/Generator/ServerGenerator/AuthG.hs | 2 +- waspc/src/Wasp/Generator/WebAppGenerator.hs | 13 ++++++-- 22 files changed, 95 insertions(+), 105 deletions(-) delete mode 100644 waspc/data/Generator/templates/server/src/auth/providers/local/types.ts delete mode 100644 waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts diff --git a/waspc/data/Generator/templates/react-app/src/entities/index.ts b/waspc/data/Generator/templates/react-app/src/entities/index.ts index a6b0269ac0..bcf115a85f 100644 --- a/waspc/data/Generator/templates/react-app/src/entities/index.ts +++ b/waspc/data/Generator/templates/react-app/src/entities/index.ts @@ -9,6 +9,9 @@ export type { {=# entities =} {= name =}, {=/ entities =} + {=# isAuthEnabled =} + type {= authEntityName =}, + {=/ isAuthEnabled =} } from '@prisma/client' export type Entity = diff --git a/waspc/data/Generator/templates/server/src/_types/index.ts b/waspc/data/Generator/templates/server/src/_types/index.ts index 8b886c0e49..8b4d0ce02e 100644 --- a/waspc/data/Generator/templates/server/src/_types/index.ts +++ b/waspc/data/Generator/templates/server/src/_types/index.ts @@ -4,8 +4,7 @@ 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" {=# isAuthEnabled =} -import { type {= userEntityName =} } from "../entities" -import { type {= authEntityName =} } from "@prisma/client" +import { type {= userEntityName =}, type {= authEntityName =} } from "../entities" {=/ isAuthEnabled =} import { type _Entity } from "./taggedEntities" import { type Payload } from "./serialization"; diff --git a/waspc/data/Generator/templates/server/src/auth/index.ts b/waspc/data/Generator/templates/server/src/auth/index.ts index 374824b697..cf74b0773e 100644 --- a/waspc/data/Generator/templates/server/src/auth/index.ts +++ b/waspc/data/Generator/templates/server/src/auth/index.ts @@ -1,7 +1 @@ -{{={= =}=}} -{=# isEmailAuthEnabled =} -export { defineAdditionalSignupFields } from './providers/email/types.js'; -{=/ isEmailAuthEnabled =} -{=# isLocalAuthEnabled =} -export { defineAdditionalSignupFields } from './providers/local/types.js'; -{=/ isLocalAuthEnabled =} \ No newline at end of file +export { defineAdditionalSignupFields } from './providers/types.js'; 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 5d87d81976..a397bfa4be 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 @@ -11,7 +11,7 @@ import type { OAuthConfig } from "../oauth/types.js"; const _waspGetUserFieldsFn = {= userFieldsFn.importIdentifier =} {=/ userFieldsFn.isDefined =} {=^ userFieldsFn.isDefined =} -import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js' +const _waspGetUserFieldsFn = undefined {=/ userFieldsFn.isDefined =} {=# configFn.isDefined =} {=& configFn.importStatement =} 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 a9747faa49..338e33e93c 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 @@ -3,7 +3,7 @@ import { EmailFromField } from "../../../email/core/types.js"; import { createAuthWithUser, findAuthWithUserBy, - deleteUser, + deleteAuth, doFakeWork, } from "../../utils.js"; import { @@ -42,7 +42,7 @@ export function getSignupRoute({ if (!isEmailResendAllowed(existingAuth, 'emailVerificationSentAt')) { return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); } - await deleteUser(existingAuth); + await deleteAuth(existingAuth); } const additionalFields = await validateAndGetAdditionalFields(userFields); @@ -55,7 +55,6 @@ export function getSignupRoute({ additionalFields, ); - // TODO: use user here const verificationLink = await createEmailVerificationLink(auth, clientRoute); try { await sendEmailVerificationEmail( diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts index ac335bf9d2..f213c80e93 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts @@ -1,5 +1,3 @@ -import { createDefineAdditionalSignupFieldsFn } from '../types.js' - export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent; export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent; @@ -13,5 +11,3 @@ type EmailContent = { export const tokenVerificationErrors = { TokenExpiredError: 'TokenExpiredError', }; - -export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn() 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 b002c361d8..5dfdbbb0bc 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 @@ -5,10 +5,15 @@ import { Email } from '../../../email/core/types.js'; import { rethrowPossiblePrismaError } from '../../utils.js' import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js'; -import { type {= userEntityUpper =} } from '../../../entities/index.js' -import { type {= authEntityUpper =} } from '@prisma/client'; +import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../../../entities/index.js' type {= authEntityUpper =}Id = {= authEntityUpper =}['id'] +type {= userEntityUpper =}Id = {= userEntityUpper =}['id'] + +type AuthWithUserId = { + id: {= authEntityUpper =}Id, + {= userFieldOnAuthEntityName =}: { id: {= userEntityUpper =}Id } +} export async function updateAuthEmailVerification(authId: {= authEntityUpper =}Id) { try { @@ -32,22 +37,22 @@ export async function updateAuthPassword(authId: {= authEntityUpper =}Id, passwo } } -export async function createEmailVerificationLink(auth: {= authEntityUpper =}, clientRoute: string) { - const token = await createEmailVerificationToken(auth); +export async function createEmailVerificationLink(authWithUserId: AuthWithUserId, clientRoute: string) { + const token = await createEmailVerificationToken(authWithUserId); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -export async function createPasswordResetLink(auth: {= authEntityUpper =}, clientRoute: string) { - const token = await createPasswordResetToken(auth); +export async function createPasswordResetLink(authWithUserId: AuthWithUserId, clientRoute: string) { + const token = await createPasswordResetToken(authWithUserId); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -async function createEmailVerificationToken(auth: {= authEntityUpper =}): Promise { - return sign(auth.id, { expiresIn: '30m' }); +async function createEmailVerificationToken(authWithUserId: AuthWithUserId): Promise { + return sign(authWithUserId.user.id, { expiresIn: '30m' }); } -async function createPasswordResetToken(auth: {= authEntityUpper =}): Promise { - return sign(auth.id, { expiresIn: '30m' }); +async function createPasswordResetToken(authWithUserId: AuthWithUserId): Promise { + return sign(authWithUserId.user.id, { expiresIn: '30m' }); } export async function sendPasswordResetEmail( diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts deleted file mode 100644 index 29cc2bfc3d..0000000000 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createDefineAdditionalSignupFieldsFn } from '../types.js' - -export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn() 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 9d3a915bac..f490d36265 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 @@ -13,14 +13,13 @@ import { findAuthWithUserBy, } from "../../utils.js" -import type { {= authEntityUpper =} } from '@prisma/client' import type { ProviderConfig, RequestWithWasp } from "../types.js" import type { GetUserFieldsFn } 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 }) { +export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn?: GetUserFieldsFn }) { const { passportStrategyName, getUserFieldsFn } = initData; const router = Router(); @@ -47,7 +46,7 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat } // Wrap call to getUserFieldsFn so we can invoke only if needed. - const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }); + const getUserFields = () => getUserFieldsFn ? getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) : Promise.resolve({}); // TODO: In the future we could make this configurable, possibly associating an external account // with the currently logged in account, or by some DB lookup. const auth = await findOrCreateAuthByAuthProvider(provider.id, providerProfile.id, getUserFields); diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts deleted file mode 100644 index a6dede224d..0000000000 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts +++ /dev/null @@ -1,12 +0,0 @@ -{{={= =}=}} -import { generateAvailableDictionaryUsername } from '../../../core/auth.js' - -export async function getUserFieldsFn(_context, _args) { - {=# isUsernameOnUserEntity =} - const username = await generateAvailableDictionaryUsername() - return { username } - {=/ isUsernameOnUserEntity =} - {=^ isUsernameOnUserEntity =} - return {} - {=/ isUsernameOnUserEntity =} -} 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 130836ebb8..ac5a56dafe 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 @@ -71,6 +71,6 @@ function ensureValidConfig(provider: ProviderConfig, config: OAuthConfig): void export type OAuthImports = { npmPackage: string; userDefinedConfigFn?: UserDefinedConfigFn; - getUserFieldsFn: GetUserFieldsFn; oAuthConfig: OAuthConfig; + getUserFieldsFn?: GetUserFieldsFn; }; 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 f7295056c8..70a2af6626 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/types.ts @@ -25,14 +25,12 @@ export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } export type PossibleAdditionalSignupFields = Expand> -export function createDefineAdditionalSignupFieldsFn() { - return function defineFields(config: { - [key in keyof PossibleAdditionalSignupFields]: FieldGetter< - PossibleAdditionalSignupFields[key] - > - }) { - return config - } +export function defineAdditionalSignupFields(config: { + [key in keyof PossibleAdditionalSignupFields]: FieldGetter< + PossibleAdditionalSignupFields[key] + > +}) { + return config } type FieldGetter = ( diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index bd13a4bc76..36c4ffaab1 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -4,8 +4,8 @@ import AuthError from '../core/AuthError.js' import HttpError from '../core/HttpError.js' import prisma from '../dbClient.js' import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js' -import { type {= userEntityUpper =} } from '../entities/index.js' -import { type Prisma, type {= authEntityUpper =} } from '@prisma/client'; +import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../entities/index.js' +import { type Prisma } from '@prisma/client'; import { throwValidationError } from './validation.js' @@ -13,14 +13,12 @@ import { throwValidationError } from './validation.js' {=& additionalSignupFields.importStatement =} {=/ additionalSignupFields.isDefined =} -import { createDefineAdditionalSignupFieldsFn, type PossibleAdditionalSignupFields } from './providers/types.js' +import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js' {=# additionalSignupFields.isDefined =} const _waspAdditionalSignupFieldsConfig = {= additionalSignupFields.importIdentifier =} {=/ additionalSignupFields.isDefined =} {=^ additionalSignupFields.isDefined =} -const _waspAdditionalSignupFieldsConfig = {} as ReturnType< - ReturnType -> +const _waspAdditionalSignupFieldsConfig = {} as ReturnType {=/ additionalSignupFields.isDefined =} export const contextWithUserEntity = { @@ -38,14 +36,22 @@ export async function findAuthWithUserBy(where: Prisma.{= authEntityUpper =}Wher return prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }}); } -export async function createAuthWithUser(data: Prisma.{= authEntityUpper =}CreateInput, additionalFields: PossibleAdditionalSignupFields) { +export async function createAuthWithUser(data: Prisma.{= authEntityUpper =}CreateInput, additionalFields?: PossibleAdditionalSignupFields) { try { return await prisma.{= authEntityLower =}.create({ data: { ...data, {= userFieldOnAuthEntityName =}: { create: { - ...additionalFields, + ...(additionalFields ?? {}), + } + } + }, + select: { + id: true, + {= userFieldOnAuthEntityName =}: { + select: { + id: true } } } @@ -55,7 +61,7 @@ export async function createAuthWithUser(data: Prisma.{= authEntityUpper =}Creat } } -export async function deleteUser(auth: {= authEntityUpper =}) { +export async function deleteAuth(auth: {= authEntityUpper =}) { try { return await prisma.{= authEntityLower =}.delete({ where: { id: auth.id } }) } catch (e) { diff --git a/waspc/data/Generator/templates/server/src/crud/_operations.ts b/waspc/data/Generator/templates/server/src/crud/_operations.ts index e69d53521b..7ffb5a1fd2 100644 --- a/waspc/data/Generator/templates/server/src/crud/_operations.ts +++ b/waspc/data/Generator/templates/server/src/crud/_operations.ts @@ -12,9 +12,7 @@ import type { {=/ isAuthEnabled =} _{= crud.entityUpper =}, } from "../_types"; -import type { - Prisma, -} from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import { Payload } from "../_types/serialization.js"; import type { {= crud.entityUpper =}, diff --git a/waspc/data/Generator/templates/server/src/entities/index.ts b/waspc/data/Generator/templates/server/src/entities/index.ts index 576f05f289..d584ec7307 100644 --- a/waspc/data/Generator/templates/server/src/entities/index.ts +++ b/waspc/data/Generator/templates/server/src/entities/index.ts @@ -9,6 +9,9 @@ export { {=# entities =} type {= name =}, {=/ entities =} + {=# isAuthEnabled =} + type {= authEntityName =}, + {=/ isAuthEnabled =} } from "@prisma/client" export type Entity = diff --git a/waspc/examples/auth-model-experiment/main.wasp b/waspc/examples/auth-model-experiment/main.wasp index 602baf5355..de6b2c722c 100644 --- a/waspc/examples/auth-model-experiment/main.wasp +++ b/waspc/examples/auth-model-experiment/main.wasp @@ -7,23 +7,23 @@ app authModelExperiment { userEntity: User, methods: { google: { - getUserFieldsFn: import { getUserFields } from "@server/google.js", - configFn: import { getGoogleConfig } from "@server/google.js", + // getUserFieldsFn: import { getUserFields } from "@server/google.js", + // configFn: import { getGoogleConfig } from "@server/google.js", + }, + // usernameAndPassword: {}, + email: { + fromField: { + name: "ToDO App", + email: "mihovil@ilakovac.com" + }, + emailVerification: { + clientRoute: EmailVerificationRoute, + }, + passwordReset: { + clientRoute: PasswordResetRoute + }, + allowUnverifiedLogin: false, }, - usernameAndPassword: {}, - // email: { - // fromField: { - // name: "ToDO App", - // email: "mihovil@ilakovac.com" - // }, - // emailVerification: { - // clientRoute: EmailVerificationRoute, - // }, - // passwordReset: { - // clientRoute: PasswordResetRoute - // }, - // allowUnverifiedLogin: false, - // }, }, onAuthFailedRedirectTo: "/login" }, diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index 634a72b251..eb549bcccc 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -299,7 +299,13 @@ genTypesAndEntitiesDirs spec = C.mkTmplFdWithDstAndData [relfile|src/entities/index.ts|] [relfile|src/entities/index.ts|] - (Just $ object ["entities" .= allEntities]) + ( Just $ + object + [ "entities" .= allEntities, + "isAuthEnabled" .= isJust maybeUserEntityName, + "authEntityName" .= DbAuth.authEntityName + ] + ) taggedEntitiesFileDraft = C.mkTmplFdWithDstAndData [relfile|src/_types/taggedEntities.ts|] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs index dd6fb9d00c..e37f9b4efa 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/EmailAuthG.hs @@ -116,5 +116,6 @@ genUtils auth = return $ C.mkTmplFdWithData tmplFile (Just tmplData) [ "userEntityUpper" .= (userEntityName :: String), "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), "authEntityUpper" .= (DbAuth.authEntityName :: String), - "authEntityLower" .= (Util.toLowerFirst DbAuth.authEntityName :: String) + "authEntityLower" .= (Util.toLowerFirst DbAuth.authEntityName :: String), + "userFieldOnAuthEntityName" .= (DbAuth.userFieldOnAuthEntityName :: String) ] diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs index 02ef415875..0d6fb2d1d0 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs @@ -28,12 +28,9 @@ genLocalAuth auth sequence [ genLoginRoute auth, genSignupRoute auth, - genLocalAuthConfig, - genFileCopy [relfile|auth/providers/local/types.ts|] + genLocalAuthConfig ] | otherwise = return [] - where - genFileCopy = return . C.mkSrcTmplFd genLocalAuthConfig :: Generator FileDraft genLocalAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs index cc89226e9e..b9b7cc3fd2 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/OAuthAuthG.hs @@ -26,7 +26,7 @@ import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.App.Auth import qualified Wasp.AppSpec.App.Auth as AS.Auth import qualified Wasp.AppSpec.App.Dependency as App.Dependency -import Wasp.AppSpec.Valid (doesUserEntityContainField, getApp) +import Wasp.AppSpec.Valid (getApp) import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider) import Wasp.Generator.AuthProviders.OAuth (OAuthAuthProvider) import qualified Wasp.Generator.AuthProviders.OAuth as OAuth @@ -39,20 +39,19 @@ import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) import Wasp.Util ((<++>)) import qualified Wasp.Util as Util -genOAuthAuth :: AS.AppSpec -> AS.Auth.Auth -> Generator [FileDraft] -genOAuthAuth spec auth +genOAuthAuth :: AS.Auth.Auth -> Generator [FileDraft] +genOAuthAuth auth | AS.Auth.isExternalAuthEnabled auth = - genOAuthHelpers spec auth + genOAuthHelpers auth <++> genOAuthProvider googleAuthProvider (AS.Auth.google . AS.Auth.methods $ auth) <++> genOAuthProvider gitHubAuthProvider (AS.Auth.gitHub . AS.Auth.methods $ auth) | otherwise = return [] -genOAuthHelpers :: AS.AppSpec -> AS.Auth.Auth -> Generator [FileDraft] -genOAuthHelpers spec auth = +genOAuthHelpers :: AS.Auth.Auth -> Generator [FileDraft] +genOAuthHelpers auth = sequence [ genCreateRouter, genTypes auth, - genDefaults spec, return $ C.mkSrcTmplFd [relfile|auth/providers/oauth/init.ts|] ] @@ -75,13 +74,6 @@ genTypes auth = return $ C.mkTmplFdWithData tmplFile (Just tmplData) tmplData = object ["userEntityName" .= userEntityName] userEntityName = AS.refName $ AS.Auth.userEntity auth -genDefaults :: AS.AppSpec -> Generator FileDraft -genDefaults spec = return $ C.mkTmplFdWithData tmplFile (Just tmplData) - where - tmplFile = C.srcDirInServerTemplatesDir [relfile|auth/providers/oauth/defaults.ts|] - tmplData = object ["isUsernameOnUserEntity" .= isUsernameOnUserEntity] - isUsernameOnUserEntity = doesUserEntityContainField spec "username" == Just True - genOAuthProvider :: OAuthAuthProvider -> Maybe AS.Auth.ExternalAuthConfig -> diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index 606d0e37fd..5a22e6643f 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -50,7 +50,7 @@ genAuth spec = case maybeAuth of ] <++> genIndexTs auth <++> genLocalAuth auth - <++> genOAuthAuth spec auth + <++> genOAuthAuth auth <++> genEmailAuth spec auth Nothing -> return [] where diff --git a/waspc/src/Wasp/Generator/WebAppGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator.hs index 89cad1261f..3810f356c0 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator.hs @@ -9,7 +9,7 @@ where import Data.Aeson (object, (.=)) import Data.Char (toLower) import Data.List (intercalate) -import Data.Maybe (fromJust) +import Data.Maybe (fromJust, isJust) import StrongPath ( Dir, File', @@ -26,6 +26,7 @@ import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import Wasp.AppSpec.App (App (webSocket)) import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Auth as AS.App.Auth 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 (..)) @@ -38,6 +39,7 @@ import Wasp.Generator.Common 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) @@ -280,8 +282,15 @@ genEntitiesDir spec = return [entitiesIndexFileDraft] C.mkTmplFdWithDstAndData [relfile|src/entities/index.ts|] [relfile|src/entities/index.ts|] - (Just $ object ["entities" .= allEntities]) + ( Just $ + object + [ "entities" .= allEntities, + "isAuthEnabled" .= isJust maybeUserEntityName, + "authEntityName" .= DbAuth.authEntityName + ] + ) allEntities = map (makeJsonWithEntityData . fst) $ AS.getDecls @AS.Entity.Entity spec + maybeUserEntityName = AS.refName . AS.App.Auth.userEntity <$> AS.App.auth (snd $ getApp spec) getIndexTs :: AppSpec -> Generator FileDraft getIndexTs spec = From 20d4fd4047fb7fbc0c8e23b9a22559e953c2555d Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 24 Nov 2023 15:39:48 +0100 Subject: [PATCH 07/62] Email authentication. Test adding signup fields --- .../server/src/auth/providers/email/utils.ts | 19 +++++++++---------- .../examples/auth-model-experiment/main.wasp | 11 +++++++---- .../auth-model-experiment/src/client/auth.jsx | 12 ++++++------ .../src/server/signup.ts | 10 ++++++++++ 4 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 waspc/examples/auth-model-experiment/src/server/signup.ts 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 5dfdbbb0bc..a51df92358 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 @@ -10,9 +10,8 @@ import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../../.. type {= authEntityUpper =}Id = {= authEntityUpper =}['id'] type {= userEntityUpper =}Id = {= userEntityUpper =}['id'] -type AuthWithUserId = { +type AuthWithId = { id: {= authEntityUpper =}Id, - {= userFieldOnAuthEntityName =}: { id: {= userEntityUpper =}Id } } export async function updateAuthEmailVerification(authId: {= authEntityUpper =}Id) { @@ -37,22 +36,22 @@ export async function updateAuthPassword(authId: {= authEntityUpper =}Id, passwo } } -export async function createEmailVerificationLink(authWithUserId: AuthWithUserId, clientRoute: string) { - const token = await createEmailVerificationToken(authWithUserId); +export async function createEmailVerificationLink(auth: AuthWithId, clientRoute: string) { + const token = await createEmailVerificationToken(auth); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -export async function createPasswordResetLink(authWithUserId: AuthWithUserId, clientRoute: string) { - const token = await createPasswordResetToken(authWithUserId); +export async function createPasswordResetLink(auth: AuthWithId, clientRoute: string) { + const token = await createPasswordResetToken(auth); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -async function createEmailVerificationToken(authWithUserId: AuthWithUserId): Promise { - return sign(authWithUserId.user.id, { expiresIn: '30m' }); +async function createEmailVerificationToken(auth: AuthWithId): Promise { + return sign(auth.id, { expiresIn: '30m' }); } -async function createPasswordResetToken(authWithUserId: AuthWithUserId): Promise { - return sign(authWithUserId.user.id, { expiresIn: '30m' }); +async function createPasswordResetToken(auth: AuthWithId): Promise { + return sign(auth.id, { expiresIn: '30m' }); } export async function sendPasswordResetEmail( diff --git a/waspc/examples/auth-model-experiment/main.wasp b/waspc/examples/auth-model-experiment/main.wasp index de6b2c722c..78cbea16cf 100644 --- a/waspc/examples/auth-model-experiment/main.wasp +++ b/waspc/examples/auth-model-experiment/main.wasp @@ -7,8 +7,8 @@ app authModelExperiment { userEntity: User, methods: { google: { - // getUserFieldsFn: import { getUserFields } from "@server/google.js", - // configFn: import { getGoogleConfig } from "@server/google.js", + getUserFieldsFn: import { getUserFields } from "@server/google.js", + configFn: import { getGoogleConfig } from "@server/google.js", }, // usernameAndPassword: {}, email: { @@ -25,7 +25,10 @@ app authModelExperiment { allowUnverifiedLogin: false, }, }, - onAuthFailedRedirectTo: "/login" + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { additionalSignupFields } from "@server/signup.js", + } }, emailSender: { provider: SMTP, @@ -86,7 +89,7 @@ page PasswordResetPage { component: import { PasswordReset } from "@client/auth.jsx", } -route EmailVerificationRoute { path: "/email-verification-", to: EmailVerificationPage } +route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage } page EmailVerificationPage { component: import { EmailVerification } from "@client/auth.jsx", } diff --git a/waspc/examples/auth-model-experiment/src/client/auth.jsx b/waspc/examples/auth-model-experiment/src/client/auth.jsx index ee1962a397..5775ba1240 100644 --- a/waspc/examples/auth-model-experiment/src/client/auth.jsx +++ b/waspc/examples/auth-model-experiment/src/client/auth.jsx @@ -1,8 +1,8 @@ import { SignupForm } from "@wasp/auth/forms/Signup"; import { LoginForm } from "@wasp/auth/forms/Login"; -// import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; -// import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; -// import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; +import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; +import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; +import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; export function Signup() { return ; @@ -13,13 +13,13 @@ export function Login() { } export function RequestPasswordReset() { - return
; + return ; } export function PasswordReset() { - return
; + return ; } export function EmailVerification() { - return
; + return ; } diff --git a/waspc/examples/auth-model-experiment/src/server/signup.ts b/waspc/examples/auth-model-experiment/src/server/signup.ts new file mode 100644 index 0000000000..11cf5bae82 --- /dev/null +++ b/waspc/examples/auth-model-experiment/src/server/signup.ts @@ -0,0 +1,10 @@ +import { defineAdditionalSignupFields } from "@wasp/auth/providers/types.js"; + +export const additionalSignupFields = defineAdditionalSignupFields({ + email({ email }) { + if (typeof email !== "string") { + throw new Error(`Email must be a string`); + } + return email; + }, +}); From 33eb3aae921db85a7162493a45f570bdfc0387cf Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 24 Nov 2023 17:10:32 +0100 Subject: [PATCH 08/62] Migration with seed scripts --- .../server/src/auth/providers/local/login.ts | 1 + .../server/src/auth/providers/local/signup.ts | 4 +- .../server/src/auth/providers/types.ts | 6 +- .../templates/server/src/auth/utils.ts | 4 +- .../server/src/core/auth/prismaMiddleware.js | 44 +++++++---- .../templates/server/src/dbClient.ts | 4 +- waspc/examples/crud-testing/main.wasp | 6 +- .../20231124155039_add_auth/migration.sql | 38 +++++++++ .../crud-testing/src/client/MainPage.tsx | 79 ++++++++++--------- .../examples/crud-testing/src/server/auth.ts | 13 ++- .../crud-testing/src/server/migrate.ts | 30 +++++++ .../examples/crud-testing/src/server/tasks.ts | 64 ++++++--------- 12 files changed, 189 insertions(+), 104 deletions(-) create mode 100644 waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql create mode 100644 waspc/examples/crud-testing/src/server/migrate.ts diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts index dc504731e5..39649e5a89 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts @@ -15,6 +15,7 @@ export default handleRejection(async (req, res) => { } try { + await verifyPassword(auth.password, userFields.password) } catch(e) { throwInvalidCredentialsError() diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts index 1839b2dc78..eed55f4b51 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts @@ -19,7 +19,9 @@ export default handleRejection(async (req, res) => { username: userFields.username, password: userFields.password, }, - additionalFields + // Using any here because we want to avoid TypeScript errors and + // rely on Prisma to validate the data. + additionalFields as any ) return res.json({ success: true }) 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 70a2af6626..42134f06ec 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/types.ts @@ -1,9 +1,9 @@ {{={= =}=}} import type { Router, Request } from 'express' -import type { {= userEntityUpper =} } from '../../entities' +import type { Prisma } from '@prisma/client' import type { Expand } from '../../universal/types' -type UserEntity = {= userEntityUpper =} +type UserEntityCreateInput = Prisma.{= userEntityUpper =}CreateInput export type ProviderConfig = { // Unique provider identifier, used as part of URL paths @@ -23,7 +23,7 @@ export type InitData = { export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } -export type PossibleAdditionalSignupFields = Expand> +export type PossibleAdditionalSignupFields = Expand> export function defineAdditionalSignupFields(config: { [key in keyof PossibleAdditionalSignupFields]: FieldGetter< diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index 36c4ffaab1..978daf70a6 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -43,7 +43,9 @@ export async function createAuthWithUser(data: Prisma.{= authEntityUpper =}Creat ...data, {= userFieldOnAuthEntityName =}: { create: { - ...(additionalFields ?? {}), + // Using any here to prevent type errors when additionalFields are not + // defined. We want Prisma to throw an error in that case. + ...(additionalFields ?? {} as any), } } }, diff --git a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js index 48aac207df..709715cdf3 100644 --- a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js +++ b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js @@ -2,26 +2,40 @@ import { hashPassword } from '../auth.js' import { PASSWORD_FIELD } from '../../auth/validation.js' +let isPasswordHashingEnabled = true + +export async function withDisabledPasswordHashing(action) { + isPasswordHashingEnabled = false + await action() + isPasswordHashingEnabled = true +} + // Make sure password is always hashed before storing to the database. const registerPasswordHashing = (prismaClient) => { prismaClient.$use(async (params, next) => { - if (params.model === '{= authEntityUpper =}') { - if (['create', 'update', 'updateMany'].includes(params.action)) { - if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { - params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) - } - } else if (params.action === 'upsert') { - if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { - params.args.create[PASSWORD_FIELD] = - await hashPassword(params.args.create[PASSWORD_FIELD]) - } - if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { - params.args.update[PASSWORD_FIELD] = - await hashPassword(params.args.update[PASSWORD_FIELD]) - } - } + if (params.model !== '{= authEntityUpper =}') { + return next(params) + } + + if (!isPasswordHashingEnabled) { + return next(params) } + if (['create', 'update', 'updateMany'].includes(params.action)) { + if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { + params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) + } + } else if (params.action === 'upsert') { + if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { + params.args.create[PASSWORD_FIELD] = + await hashPassword(params.args.create[PASSWORD_FIELD]) + } + if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { + params.args.update[PASSWORD_FIELD] = + await hashPassword(params.args.update[PASSWORD_FIELD]) + } + } + return next(params) }) } diff --git a/waspc/data/Generator/templates/server/src/dbClient.ts b/waspc/data/Generator/templates/server/src/dbClient.ts index fead2ab166..cc5047516b 100644 --- a/waspc/data/Generator/templates/server/src/dbClient.ts +++ b/waspc/data/Generator/templates/server/src/dbClient.ts @@ -6,7 +6,9 @@ import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js' {=/ isAuthEnabled =} const createDbClient = () => { - const prismaClient = new Prisma.PrismaClient() + const prismaClient = new Prisma.PrismaClient({ + log: ['query', 'info', 'warn', 'error'], + }) {=# isAuthEnabled =} registerAuthMiddleware(prismaClient) diff --git a/waspc/examples/crud-testing/main.wasp b/waspc/examples/crud-testing/main.wasp index 06f0573feb..1fecd70275 100644 --- a/waspc/examples/crud-testing/main.wasp +++ b/waspc/examples/crud-testing/main.wasp @@ -20,7 +20,10 @@ app crudTesting { ("zod", "^3.22.2") ], db: { - system: PostgreSQL + system: PostgreSQL, + seeds: [ + import { migrateAuth } from "@server/migrate.js" + ] } } @@ -91,5 +94,4 @@ job simplePrintJob { action customSignup { fn: import { signup } from "@server/auth.js", - entities: [User] } \ No newline at end of file diff --git a/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql b/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql new file mode 100644 index 0000000000..280d1f5fc5 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql @@ -0,0 +1,38 @@ +-- 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/src/client/MainPage.tsx b/waspc/examples/crud-testing/src/client/MainPage.tsx index 96d604d314..e46c018781 100644 --- a/waspc/examples/crud-testing/src/client/MainPage.tsx +++ b/waspc/examples/crud-testing/src/client/MainPage.tsx @@ -1,64 +1,64 @@ -import "./Main.css"; +import './Main.css' -import React, { useState } from "react"; -import { Link, routes } from "@wasp/router"; -import logout from "@wasp/auth/logout"; +import React, { useState } from 'react' +import { Link, routes } from '@wasp/router' +import logout from '@wasp/auth/logout' -import { tasks as tasksCrud } from "@wasp/crud/tasks"; -import { User } from "@wasp/entities"; +import { tasks as tasksCrud } from '@wasp/crud/tasks' +import { User } from '@wasp/entities' const MainPage = ({ user }: { user: User }) => { - const { data: tasks, isLoading } = tasksCrud.getAll.useQuery(); + const { data: tasks, isLoading } = tasksCrud.getAll.useQuery() - type Task = NonNullable[number]; + type Task = NonNullable[number] - const createTask = tasksCrud.create.useAction(); - const deleteTask = tasksCrud.delete.useAction(); - const updateTask = tasksCrud.update.useAction(); + const createTask = tasksCrud.create.useAction() + const deleteTask = tasksCrud.delete.useAction() + const updateTask = tasksCrud.update.useAction() - const [newTaskTitle, setNewTaskTitle] = useState(""); - const [editTaskTitle, setEditTaskTitle] = useState(""); - const [error, setError] = useState(""); - const [isEditing, setIsEditing] = useState(null); + const [newTaskTitle, setNewTaskTitle] = useState('') + const [editTaskTitle, setEditTaskTitle] = useState('') + const [error, setError] = useState('') + const [isEditing, setIsEditing] = useState(null) async function handleCreateTask(e: React.FormEvent) { - setError(""); - e.preventDefault(); + setError('') + e.preventDefault() try { await createTask({ title: newTaskTitle, - }); + }) } catch (err: unknown) { - setError(`Error creating task: ${err as any}`); + setError(`Error creating task: ${err as any}`) } - setNewTaskTitle(""); + setNewTaskTitle('') } async function handleUpdateTask(e: React.FormEvent) { - setError(""); - e.preventDefault(); + setError('') + e.preventDefault() try { - await updateTask({ id: isEditing!, title: editTaskTitle }); + await updateTask({ id: isEditing!, title: editTaskTitle }) } catch (err: unknown) { - setError("Error updating task."); + setError('Error updating task.') } - setIsEditing(null); - setEditTaskTitle(""); + setIsEditing(null) + setEditTaskTitle('') } function handleStartEditing(task: { id: number; title: string }) { - setIsEditing(task.id); - setEditTaskTitle(task.title); + setIsEditing(task.id) + setEditTaskTitle(task.title) } async function handleTaskDelete(task: { id: number }) { try { - if (!confirm("Are you sure you want to delete this task?")) { - return; + if (!confirm('Are you sure you want to delete this task?')) { + return } - await deleteTask({ id: task.id }); + await deleteTask({ id: task.id }) } catch (err: unknown) { - setError("Error deleting task."); + setError('Error deleting task.') } } @@ -92,12 +92,13 @@ const MainPage = ({ user }: { user: User }) => {
- Visit {task.title} at{" "} + Visit {task.title} at{' '} {routes.DetailRoute.build({ - params: { id: task.id, something: "else" }, - })} + params: { id: task.id, something: 'else' }, + })}{' '} + by {task.user.auth?.username}
@@ -126,7 +127,7 @@ const MainPage = ({ user }: { user: User }) => {
- ); -}; + ) +} -export default MainPage; +export default MainPage diff --git a/waspc/examples/crud-testing/src/server/auth.ts b/waspc/examples/crud-testing/src/server/auth.ts index 32dcd388e8..3f5ad8bc7d 100644 --- a/waspc/examples/crud-testing/src/server/auth.ts +++ b/waspc/examples/crud-testing/src/server/auth.ts @@ -5,6 +5,7 @@ import { ensureValidPassword, ensureValidUsername, } from '@wasp/auth/validation.js' +import prisma from '@wasp/dbClient.js' export const fields = defineAdditionalSignupFields({ address: (data) => { @@ -21,7 +22,7 @@ export const fields = defineAdditionalSignupFields({ } return result.data }, -}) +} as any) import { CustomSignup } from '@wasp/actions/types' @@ -38,17 +39,21 @@ type CustomSignupOutput = { export const signup: CustomSignup< CustomSignupInput, CustomSignupOutput -> = async (args, { entities: { User } }) => { +> = async (args) => { ensureValidUsername(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { - await User.create({ + await prisma.auth.create({ data: { username: args.username, password: args.password, - address: args.address, + user: { + create: { + address: args.address, + } as any, + }, }, }) } catch (e: any) { diff --git a/waspc/examples/crud-testing/src/server/migrate.ts b/waspc/examples/crud-testing/src/server/migrate.ts new file mode 100644 index 0000000000..67f511759f --- /dev/null +++ b/waspc/examples/crud-testing/src/server/migrate.ts @@ -0,0 +1,30 @@ +import prisma from '@wasp/dbClient.js' +import { withDisabledPasswordHashing } from '@wasp/core/auth/prismaMiddleware.js' + +export async function migrateAuth(db: typeof prisma) { + const users = await db.user.findMany() + + for (let user of users) { + const auth = await db.auth.findUnique({ + where: { + username: user.username, + }, + }) + if (auth) { + continue + } + withDisabledPasswordHashing(() => + db.auth.create({ + data: { + username: user.username, + password: user.password, + user: { + connect: { + id: user.id, + }, + }, + }, + }) + ) + } +} diff --git a/waspc/examples/crud-testing/src/server/tasks.ts b/waspc/examples/crud-testing/src/server/tasks.ts index a79116b848..4b669639f6 100644 --- a/waspc/examples/crud-testing/src/server/tasks.ts +++ b/waspc/examples/crud-testing/src/server/tasks.ts @@ -1,59 +1,47 @@ -import HttpError from "@wasp/core/HttpError.js"; -import type { GetQuery, GetAllQuery, CreateAction } from "@wasp/crud/tasks"; -import { Task, User } from "@wasp/entities"; -import { simplePrintJob } from "@wasp/jobs/simplePrintJob.js"; +import HttpError from '@wasp/core/HttpError.js' +import type { GetQuery, GetAllQuery, CreateAction } from '@wasp/crud/tasks' +import { Task } from '@wasp/entities' export const getTask = (async (args, context) => { return context.entities.Task.findUnique({ where: { id: args.id }, include: { - user: { select: { username: true } }, + user: { + // include: { + // auth: { + // select: { username: true }, + // }, + // }, + }, }, - }); -}) satisfies GetQuery< - { id: Task["id"] }, - | (Task & { - user: Pick; - }) - | null ->; + }) +}) satisfies GetQuery<{ id: Task['id'] }, {}> export const getAllTasks = (async (args, context) => { - const result = await simplePrintJob.submit({ - name: "moje ime", - }); - - await new Promise((resolve) => setTimeout(resolve, 3000)); - - const details = await result.pgBoss.details(); - - if (details && details.state === "completed") { - console.log("Job started with data:", details.data); - console.log("Job completed with output:", details.output.tasks); - } else if (details) { - console.log("Job state and output", details.state, details.output); - } - return context.entities.Task.findMany({ - orderBy: { id: "desc" }, + orderBy: { id: 'desc' }, select: { id: true, title: true, user: { - select: { - username: true, - }, + // include: { + // auth: { + // select: { + // username: true, + // }, + // }, + // }, }, }, - }); -}) satisfies GetAllQuery<{}, {}>; + }) +}) satisfies GetAllQuery<{}, {}> export const createTask = (async (args, context) => { if (!context.user) { - throw new HttpError(401, "You must be logged in to create a task."); + throw new HttpError(401, 'You must be logged in to create a task.') } if (!args.title) { - throw new HttpError(400, "Task title is required."); + throw new HttpError(400, 'Task title is required.') } return context.entities.Task.create({ data: { @@ -64,5 +52,5 @@ export const createTask = (async (args, context) => { }, }, }, - }); -}) satisfies CreateAction<{ title: Task["title"] }, Task>; + }) +}) satisfies CreateAction<{ title: Task['title'] }, Task> From 993e39d4e13069aec7ce67f381dd737041b6f3d2 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Fri, 24 Nov 2023 17:26:15 +0100 Subject: [PATCH 09/62] Migrate Todo app --- .../templates/server/src/_types/index.ts | 2 +- .../20231124161113_initial/migration.sql | 38 ++++++++++++++++++ .../migration.sql | 26 ++++++++++++ waspc/examples/todoApp/src/client/App.tsx | 2 +- .../todoApp/src/client/pages/ProfilePage.tsx | 11 ++--- waspc/examples/todoApp/src/server/apis.ts | 26 +++++++----- waspc/examples/todoApp/src/server/dbSeeds.ts | 40 ++++++++++++++----- .../examples/todoApp/src/server/webSocket.ts | 4 +- waspc/examples/todoApp/todoApp.wasp | 19 --------- 9 files changed, 118 insertions(+), 50 deletions(-) create mode 100644 waspc/examples/todoApp/migrations/20231124161113_initial/migration.sql create mode 100644 waspc/examples/todoApp/migrations/20231124161208_remove_extras/migration.sql diff --git a/waspc/data/Generator/templates/server/src/_types/index.ts b/waspc/data/Generator/templates/server/src/_types/index.ts index 8b4d0ce02e..6d35bcaf5a 100644 --- a/waspc/data/Generator/templates/server/src/_types/index.ts +++ b/waspc/data/Generator/templates/server/src/_types/index.ts @@ -84,6 +84,6 @@ type ContextWithUser = Expand & { // these two things would live in the same place: // https://github.com/wasp-lang/wasp/issues/965 export type SanitizedUser = {= userEntityName =} & { - {= authFieldOnUserEntityName =}: Omit<{= authEntityName =}, 'password'> + {= authFieldOnUserEntityName =}: Omit<{= authEntityName =}, 'password'> | null } {=/ isAuthEnabled =} diff --git a/waspc/examples/todoApp/migrations/20231124161113_initial/migration.sql b/waspc/examples/todoApp/migrations/20231124161113_initial/migration.sql new file mode 100644 index 0000000000..280d1f5fc5 --- /dev/null +++ b/waspc/examples/todoApp/migrations/20231124161113_initial/migration.sql @@ -0,0 +1,38 @@ +-- 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/todoApp/migrations/20231124161208_remove_extras/migration.sql b/waspc/examples/todoApp/migrations/20231124161208_remove_extras/migration.sql new file mode 100644 index 0000000000..8c10eaf5f5 --- /dev/null +++ b/waspc/examples/todoApp/migrations/20231124161208_remove_extras/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `emailVerificationSentAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `isEmailVerified` on the `User` table. All the data in the column will be lost. + - 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 `passwordResetSentAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the `SocialLogin` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "SocialLogin" DROP CONSTRAINT "SocialLogin_userId_fkey"; + +-- DropIndex +DROP INDEX "User_email_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "email", +DROP COLUMN "emailVerificationSentAt", +DROP COLUMN "isEmailVerified", +DROP COLUMN "password", +DROP COLUMN "passwordResetSentAt"; + +-- DropTable +DROP TABLE "SocialLogin"; diff --git a/waspc/examples/todoApp/src/client/App.tsx b/waspc/examples/todoApp/src/client/App.tsx index 94c45f9541..4e51258eb8 100644 --- a/waspc/examples/todoApp/src/client/App.tsx +++ b/waspc/examples/todoApp/src/client/App.tsx @@ -27,7 +27,7 @@ export function App({ children }: any) { {user && (
- Hello, {user.email} + Hello, {user.auth?.email}
- - - ) : ( - <> -
- - {task.title} - {" "} - ({task.user.email}) -
- - handleStartEditing(task)} - className="button" - > - Edit - - - )} -
- ))} - {tasks?.length === 0 &&
No tasks yet.
} -
-
- - setNewTaskTitle(e.target.value)} - /> - - -
- - -
- ); -}; - -export default MainPage; diff --git a/waspc/examples/auth-model-experiment/src/client/auth.jsx b/waspc/examples/auth-model-experiment/src/client/auth.jsx deleted file mode 100644 index 5775ba1240..0000000000 --- a/waspc/examples/auth-model-experiment/src/client/auth.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import { SignupForm } from "@wasp/auth/forms/Signup"; -import { LoginForm } from "@wasp/auth/forms/Login"; -import { VerifyEmailForm } from "@wasp/auth/forms/VerifyEmail"; -import { ForgotPasswordForm } from "@wasp/auth/forms/ForgotPassword"; -import { ResetPasswordForm } from "@wasp/auth/forms/ResetPassword"; - -export function Signup() { - return ; -} - -export function Login() { - return ; -} - -export function RequestPasswordReset() { - return ; -} - -export function PasswordReset() { - return ; -} - -export function EmailVerification() { - return ; -} diff --git a/waspc/examples/auth-model-experiment/src/client/tsconfig.json b/waspc/examples/auth-model-experiment/src/client/tsconfig.json deleted file mode 100644 index d501a4193a..0000000000 --- a/waspc/examples/auth-model-experiment/src/client/tsconfig.json +++ /dev/null @@ -1,55 +0,0 @@ -// =============================== 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, - // Wasp needs the following settings enable IDE support in your source - // files. Editing them might break features like import autocompletion and - // definition lookup. Don't change them unless you know what you're doing. - // - // The relative path to the generated web app's root directory. This must be - // set to define the "paths" option. - "baseUrl": "../../.wasp/out/web-app/", - "paths": { - // Resolve all "@wasp" imports to the generated source code. - "@wasp/*": [ - "src/*" - ], - // Resolve all non-relative imports to the correct node module. Source: - // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping - "*": [ - // Start by looking for the definiton inside the node modules root - // directory... - "node_modules/*", - // ... If that fails, try to find it inside definitely-typed type - // definitions. - "node_modules/@types/*" - ] - }, - // Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots - "typeRoots": [ - "../../.wasp/out/web-app/node_modules/@types" - ], - // 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/auth-model-experiment/src/client/vite-env.d.ts b/waspc/examples/auth-model-experiment/src/client/vite-env.d.ts deleted file mode 100644 index 1623b9c79c..0000000000 --- a/waspc/examples/auth-model-experiment/src/client/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/waspc/examples/auth-model-experiment/src/client/vite.config.ts b/waspc/examples/auth-model-experiment/src/client/vite.config.ts deleted file mode 100644 index 6e16a4c901..0000000000 --- a/waspc/examples/auth-model-experiment/src/client/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vite"; - -export default defineConfig({ - server: { - open: false, - }, -}); diff --git a/waspc/examples/auth-model-experiment/src/client/waspLogo.png b/waspc/examples/auth-model-experiment/src/client/waspLogo.png deleted file mode 100644 index d39a9443a8153b158b76f51dda2e42f3b34a9169..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24877 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 { - console.log(data); - - return { - email: data.profile.emails[0].value, - }; -}; - -export const getGoogleConfig = () => { - return { - clientID: process.env["GOOGLE_CLIENT_ID"], - clientSecret: process.env["GOOGLE_CLIENT_SECRET"], - scope: ["profile", "email"], - }; -}; diff --git a/waspc/examples/auth-model-experiment/src/server/signup.ts b/waspc/examples/auth-model-experiment/src/server/signup.ts deleted file mode 100644 index 11cf5bae82..0000000000 --- a/waspc/examples/auth-model-experiment/src/server/signup.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineAdditionalSignupFields } from "@wasp/auth/providers/types.js"; - -export const additionalSignupFields = defineAdditionalSignupFields({ - email({ email }) { - if (typeof email !== "string") { - throw new Error(`Email must be a string`); - } - return email; - }, -}); diff --git a/waspc/examples/auth-model-experiment/src/server/tasks.ts b/waspc/examples/auth-model-experiment/src/server/tasks.ts deleted file mode 100644 index 52cd059c9f..0000000000 --- a/waspc/examples/auth-model-experiment/src/server/tasks.ts +++ /dev/null @@ -1,62 +0,0 @@ -import HttpError from "@wasp/core/HttpError.js"; -import type { GetQuery, GetAllQuery, CreateAction } from "@wasp/crud/tasks"; -import { Task, User } from "@wasp/entities"; -import { Auth } from "@prisma/client"; - -export const getTask = (async (args, context) => { - return context.entities.Task.findUnique({ - where: { id: args.id }, - include: { - user: { - include: { - auth: { - select: { - username: true, - }, - }, - }, - }, - }, - }); -}) satisfies GetQuery< - { id: Task["id"] }, - { user: User & { auth: Pick | null } } | null ->; - -export const getAllTasks = (async (args, context) => { - return context.entities.Task.findMany({ - orderBy: { id: "desc" }, - select: { - id: true, - title: true, - user: { - include: { - auth: { - select: { - username: true, - }, - }, - }, - }, - }, - }); -}) satisfies GetAllQuery<{}, {}>; - -export const createTask = (async (args, context) => { - if (!context.user) { - throw new HttpError(401, "You must be logged in to create a task."); - } - if (!args.title) { - throw new HttpError(400, "Task title is required."); - } - return context.entities.Task.create({ - data: { - title: args.title!, - user: { - connect: { - id: context.user.id, - }, - }, - }, - }); -}) satisfies CreateAction<{ title: Task["title"] }, Task>; diff --git a/waspc/examples/auth-model-experiment/src/server/tsconfig.json b/waspc/examples/auth-model-experiment/src/server/tsconfig.json deleted file mode 100644 index 70a79b44ee..0000000000 --- a/waspc/examples/auth-model-experiment/src/server/tsconfig.json +++ /dev/null @@ -1,48 +0,0 @@ -// =============================== 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": { - // Allows default imports. - "esModuleInterop": true, - "allowJs": true, - "strict": true, - // Wasp needs the following settings enable IDE support in your source - // files. Editing them might break features like import autocompletion and - // definition lookup. Don't change them unless you know what you're doing. - // - // The relative path to the generated web app's root directory. This must be - // set to define the "paths" option. - "baseUrl": "../../.wasp/out/server/", - "paths": { - // Resolve all "@wasp" imports to the generated source code. - "@wasp/*": [ - "src/*" - ], - // Resolve all non-relative imports to the correct node module. Source: - // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping - "*": [ - // Start by looking for the definiton inside the node modules root - // directory... - "node_modules/*", - // ... If that fails, try to find it inside definitely-typed type - // definitions. - "node_modules/@types/*" - ] - }, - // Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots - "typeRoots": [ - "../../.wasp/out/server/node_modules/@types" - ], - // 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/auth-model-experiment/src/shared/tsconfig.json b/waspc/examples/auth-model-experiment/src/shared/tsconfig.json deleted file mode 100644 index 20fcac8431..0000000000 --- a/waspc/examples/auth-model-experiment/src/shared/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable default imports in TypeScript. - "esModuleInterop": true, - "allowJs": true, - // The following settings enable IDE support in user-provided source files. - // Editing them might break features like import autocompletion and - // definition lookup. Don't change them unless you know what you're doing. - // - // The relative path to the generated web app's root directory. This must be - // set to define the "paths" option. - "baseUrl": "../../.wasp/out/server/", - "paths": { - // Resolve all non-relative imports to the correct node module. Source: - // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping - "*": [ - // Start by looking for the definiton inside the node modules root - // directory... - "node_modules/*", - // ... If that fails, try to find it inside definitely-typed type - // definitions. - "node_modules/@types/*" - ] - }, - // Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots - "typeRoots": ["../../.wasp/out/server/node_modules/@types"] - } -} From 4d0e3e4fdd8608a9edf4ecab1534be6def979142 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 27 Nov 2023 14:23:02 +0100 Subject: [PATCH 12/62] Cleanup --- waspc/src/Wasp/Generator/DbGenerator/Auth.hs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs index 016875c5a1..6fca5245a0 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs @@ -38,13 +38,14 @@ providersFieldOnAuthEntityName = "providers" injectAuth :: Maybe (String, AS.Entity.Entity) -> [(String, AS.Entity.Entity)] -> Generator [(String, AS.Entity.Entity)] injectAuth Nothing entities = return entities injectAuth (Just (userEntityName, userEntity)) entities = do - userEntityIdType <- getUserEntityId userEntity + userEntityIdType <- getUserEntityIdType userEntity authEntity <- makeAuthEntity userEntityIdType userEntityName providerEntity <- makeProviderEntity - return $ injectAuthIntoUserEntity userEntityName $ entities ++ [authEntity, providerEntity] + let entitiesWithAuth = injectAuthIntoUserEntity userEntityName entities + return $ entitiesWithAuth ++ [authEntity, providerEntity] -getUserEntityId :: AS.Entity.Entity -> Generator String -getUserEntityId entity = +getUserEntityIdType :: AS.Entity.Entity -> Generator String +getUserEntityIdType entity = show . Psl.Model.Field._type <$> AS.Entity.getIdField entity & ( \case Nothing -> logAndThrowGeneratorError $ GenericGeneratorError "User entity does not have an id field." From a768c36b3fb635442aef4c3b15868f93cf00dd41 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 27 Nov 2023 15:15:12 +0100 Subject: [PATCH 13/62] Updates tests --- .../templates/server/src/auth/utils.ts | 8 -- .../waspComplexTest-golden/files.manifest | 1 - .../waspComplexTest/.wasp/out/.waspchecksums | 33 +++----- .../.wasp/out/db/schema.prisma | 22 ++++++ .../db/schema.prisma.wasp-generate-checksum | 2 +- .../.wasp/out/server/src/_types/index.ts | 6 +- .../src/auth/providers/config/google.ts | 2 +- .../src/auth/providers/oauth/createRouter.ts | 47 ++++++----- .../src/auth/providers/oauth/defaults.ts | 6 -- .../server/src/auth/providers/oauth/init.ts | 2 +- .../server/src/auth/providers/oauth/types.ts | 4 +- .../out/server/src/auth/providers/types.ts | 24 +++--- .../.wasp/out/server/src/auth/utils.ts | 37 ++++++--- .../.wasp/out/server/src/core/auth.js | 27 ++++++- .../server/src/core/auth/prismaMiddleware.js | 44 +++++++---- .../.wasp/out/server/src/crud/tasks.ts | 4 +- .../.wasp/out/server/src/entities/index.ts | 1 + .../.wasp/out/web-app/src/entities/index.ts | 1 + .../20231127133821_use_new_auth/migration.sql | 65 +++++++++++++++ .../examples/todoApp/src/server/apis.ts | 26 +++--- .../examples/todoApp/todoApp.wasp | 19 ----- waspc/headless-test/start.js | 19 ++--- waspc/src/Wasp/AppSpec/Valid.hs | 16 +++- waspc/src/Wasp/Generator/DbGenerator/Auth.hs | 11 ++- waspc/test/AppSpec/ValidTest.hs | 79 +++++-------------- 25 files changed, 289 insertions(+), 217 deletions(-) delete mode 100644 waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts create mode 100644 waspc/headless-test/examples/todoApp/migrations/20231127133821_use_new_auth/migration.sql diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index 978daf70a6..bd158aae76 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -48,14 +48,6 @@ export async function createAuthWithUser(data: Prisma.{= authEntityUpper =}Creat ...(additionalFields ?? {} as any), } } - }, - select: { - id: true, - {= userFieldOnAuthEntityName =}: { - select: { - id: true - } - } } }) } catch (e) { diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest index a0b6cd1ea8..99dcc82b37 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest @@ -24,7 +24,6 @@ waspComplexTest/.wasp/out/server/src/app.js 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/defaults.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 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 f9c0e57cc9..17d4041167 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" ], - "cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1" + "be2dab40b2031f2d4f8dfa0bf744b31201cbe0b634a15de2769e5eb6ed901bf4" ], [ [ @@ -88,7 +88,7 @@ "file", "server/src/_types/index.ts" ], - "aa5f2c417b5732f732241362fb456b8d068a270d245d692e6530defbb035e778" + "6c3490eadc64bce007adff63a21afa5521517432f8816d601bc0f518f31a1d15" ], [ [ @@ -137,7 +137,7 @@ "file", "server/src/auth/providers/config/google.ts" ], - "ff060f3cb2437755867b9548dce153bd38b985dc3fc102073bd698d3d7fa23a7" + "62e519ae90c87e1032e53d089a8d6106331a278ecd1293767c8e8e9cb4848f6a" ], [ [ @@ -151,42 +151,35 @@ "file", "server/src/auth/providers/oauth/createRouter.ts" ], - "63dbe409a2de70c55e3f4c01b5faa1da6d09ac0947ff90133c6c616c57ad75c7" - ], - [ - [ - "file", - "server/src/auth/providers/oauth/defaults.ts" - ], - "2bff7ab070b402aac4ea69a950c0ef6ba7e58c27d4727bdf6046961cb7c0dd48" + "c14b5fef9b96921081ab5a8412847ea2bf4dc00f96850369e691c9c1955502a8" ], [ [ "file", "server/src/auth/providers/oauth/init.ts" ], - "ff022c6f9132db9c3833afd12018fecfcdd3243cbf9e5d1b69367864301fe085" + "cef00c764f6c6923c0138f114eaf0484ad30c4e9dde7f6b44a143061909a8ba1" ], [ [ "file", "server/src/auth/providers/oauth/types.ts" ], - "81c3ff4d945025fc2530b57db7ceecb5c14857e84d98e8c11f045282c25efe37" + "00c951bd5dae77b7aedca90c0847f6e861e7f151e89b1906e794469981191b47" ], [ [ "file", "server/src/auth/providers/types.ts" ], - "323555d76755fe32b21084f063caf931faabcb5937c279cc706bbecad3361d43" + "691e1bd811dd93e8ffffcf323b44a530445661c4f72ba698cff1f05f9fc88124" ], [ [ "file", "server/src/auth/utils.ts" ], - "ab1f6a90dca62ba0b3b9e1931209c4db1ea4fd0c757fadd27e90b527bcd869e0" + "20cadaa8cc661879ebdd0510fe0d855f880a28be9e6dfa5de0ee9c1d2c58bdf7" ], [ [ @@ -221,21 +214,21 @@ "file", "server/src/core/auth.js" ], - "cb1941ba655c0300bbabda6b51096acdfcd28665ba2f07de676bacb26bd8cddc" + "53e1caf098d8332907307ce4e1b7cbc6f3be850ade95758065cffb7fc6b79e32" ], [ [ "file", "server/src/core/auth/prismaMiddleware.js" ], - "a9ccf84f089cf98a022fa800e5b3cc06d3a8b69f5785718dc068286836fd77fa" + "efef565b1cb2a551040e50f0e631e374966a42716aa6e831282361c2bd731c2c" ], [ [ "file", "server/src/crud/tasks.ts" ], - "5c7e55d9eecf8e54822b90662727fcab47c6171c0c49043b25f4e7073ecbe085" + "2c4e1f94939adf825df14624940019889394a0e56cdea2855686d67e0c08458a" ], [ [ @@ -298,7 +291,7 @@ "file", "server/src/entities/index.ts" ], - "783fbc250d0628073328625ff299f120ac8bef45232c10b7d7b5897d42b788c1" + "0c27dd3ea5b7f01e83dbeb377475a1023c30337524ec83adc994f30174af1850" ], [ [ @@ -781,7 +774,7 @@ "file", "web-app/src/entities/index.ts" ], - "cfb9f8237f80aea777840621702d03803172b005b3aaee2fc0be6c9f1b1c8414" + "f05a58eef62f55439059a21159b150bd1fdf8f581df832ffcdbc73cf13439ad3" ], [ [ 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 8b5c55f36b..39fee2d67c 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 @@ -14,6 +14,7 @@ model User { username String @unique password String externalAuthAssociations SocialLogin[] + auth Auth? } model SocialLogin { @@ -32,3 +33,24 @@ model Task { isDone Boolean @default(false) } +model Auth { + id String @id @default(uuid()) + email String? @unique + username String? @unique + password String? + isEmailVerified Boolean @default(false) + emailVerificationSentAt DateTime? + passwordResetSentAt DateTime? + userId Int? @unique + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + providers SocialAuthProvider[] + +} +model SocialAuthProvider { + id String @id @default(uuid()) + provider String + providerId String + authId String + auth Auth @relation(fields: [authId], references: [id], onDelete: Cascade) + +} 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 70f188e833..7f0ec6742d 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 @@ -cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1 \ No newline at end of file +be2dab40b2031f2d4f8dfa0bf744b31201cbe0b634a15de2769e5eb6ed901bf4 \ No newline at end of file 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 333f64f26b..7e19a86134 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 @@ -2,7 +2,7 @@ import { type Expand } from "../universal/types.js"; 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 { type User } from "../entities" +import { type User, type Auth } from "../entities" import { type _Entity } from "./taggedEntities" import { type Payload } from "./serialization"; @@ -77,4 +77,6 @@ type ContextWithUser = Expand & { // 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 SanitizedUser = Omit +export type SanitizedUser = User & { + auth: Omit | null +} 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 a2e0363bd3..0525771050 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"; -import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js' +const _waspGetUserFieldsFn = undefined const _waspUserDefinedConfigFn = undefined const _waspOAuthConfig: OAuthConfig = { 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 85a04a93c5..5dd4f8bdea 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 @@ -1,21 +1,24 @@ import { Router } from "express" import passport from "passport" -import { v4 as uuidv4 } from 'uuid' import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js' import { sign } from '../../../core/auth.js' -import { authConfig, contextWithUserEntity, createUser } from "../../utils.js" +import { + authConfig, + contextWithUserEntity, + createAuthWithUser, + findAuthWithUserBy, +} from "../../utils.js" -import type { User } from '../../../entities'; import type { ProviderConfig, RequestWithWasp } from "../types.js" import type { GetUserFieldsFn } 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 }) { +export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn?: GetUserFieldsFn }) { const { passportStrategyName, getUserFieldsFn } = initData; const router = Router(); @@ -42,12 +45,12 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat } // Wrap call to getUserFieldsFn so we can invoke only if needed. - const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }); + const getUserFields = () => getUserFieldsFn ? getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) : Promise.resolve({}); // TODO: In the future we could make this configurable, possibly associating an external account // with the currently logged in account, or by some DB lookup. - const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields); + const auth = await findOrCreateAuthByAuthProvider(provider.id, providerProfile.id, getUserFields); - const token = await sign(user.id); + const token = await sign(auth.user.id); res.json({ token }); }) ) @@ -55,32 +58,38 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat return router; } -async function findOrCreateUserByExternalAuthAssociation( +async function findOrCreateAuthByAuthProvider( provider: string, providerId: string, getUserFields: () => ReturnType, -): Promise { +) { // Attempt to find a User by an external auth association. - const externalAuthAssociation = await prisma.socialLogin.findFirst({ + const authProvider = await prisma.socialAuthProvider.findFirst({ where: { provider, providerId }, - include: { user: true } + include: { + auth: { + include: { + user: true + } + } + } }) - if (externalAuthAssociation) { - return externalAuthAssociation.user + if (authProvider) { + return authProvider.auth } // No external auth association linkage found. Create a new User using details from // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User. const userFields = await getUserFields() - const userAndExternalAuthAssociation = { - ...userFields, - // TODO: Decouple social from usernameAndPassword auth. - password: uuidv4(), - externalAuthAssociations: { + const authAndProviderData = { + providers: { create: [{ provider, providerId }] } } - return createUser(userAndExternalAuthAssociation) + // TODO(miho): decide if we want to keep the custom data on User or Auth? + const auth = await createAuthWithUser(authAndProviderData, userFields) + // NOTE: we are fetching the auth again becuase it incldues nested user + return findAuthWithUserBy({ id: auth.id }); } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts deleted file mode 100644 index ad3df3f415..0000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { generateAvailableDictionaryUsername } from '../../../core/auth.js' - -export async function getUserFieldsFn(_context, _args) { - const username = await generateAvailableDictionaryUsername() - return { username } -} 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 130836ebb8..ac5a56dafe 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 @@ -71,6 +71,6 @@ function ensureValidConfig(provider: ProviderConfig, config: OAuthConfig): void export type OAuthImports = { npmPackage: string; userDefinedConfigFn?: UserDefinedConfigFn; - getUserFieldsFn: GetUserFieldsFn; oAuthConfig: OAuthConfig; + getUserFieldsFn?: GetUserFieldsFn; }; 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 1589e88876..ca1e7a3f50 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 @@ -8,11 +8,11 @@ export type OAuthConfig = { scope?: string[]; } -export type CreateOAuthUser = Omit +export type UserFieldsFromOAuthSignup = Prisma.UserCreateInput export type UserDefinedConfigFn = () => { [key: string]: any } export type GetUserFieldsFn = ( context: typeof contextWithUserEntity, args: { profile: { [key: string]: any } }, -) => Promise +) => 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 0889298c0a..a404dd6865 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 @@ -1,7 +1,9 @@ import type { Router, Request } from 'express' -import type { User } from '../../entities' +import type { Prisma } from '@prisma/client' import type { Expand } from '../../universal/types' +type UserEntityCreateInput = Prisma.UserCreateInput + export type ProviderConfig = { // Unique provider identifier, used as part of URL paths id: string; @@ -20,20 +22,14 @@ export type InitData = { export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } -export function createDefineAdditionalSignupFieldsFn< - // Wasp already includes these fields in the signup process - ExistingFields extends keyof User, - PossibleAdditionalFields = Expand< - Partial> +export type PossibleAdditionalSignupFields = Expand> + +export function defineAdditionalSignupFields(config: { + [key in keyof PossibleAdditionalSignupFields]: FieldGetter< + PossibleAdditionalSignupFields[key] > ->() { - return function defineFields(config: { - [key in keyof PossibleAdditionalFields]: FieldGetter< - PossibleAdditionalFields[key] - > - }) { - return config - } +}) { + return config } type FieldGetter = ( 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 b6d503bec0..cf718bb303 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 @@ -3,16 +3,14 @@ import AuthError from '../core/AuthError.js' import HttpError from '../core/HttpError.js' import prisma from '../dbClient.js' import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js' -import { type User } from '../entities/index.js' +import { type User, type Auth } from '../entities/index.js' import { type Prisma } from '@prisma/client'; import { throwValidationError } from './validation.js' -import { createDefineAdditionalSignupFieldsFn } from './providers/types.js' -const _waspAdditionalSignupFieldsConfig = {} as ReturnType< - ReturnType> -> +import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js' +const _waspAdditionalSignupFieldsConfig = {} as ReturnType export const contextWithUserEntity = { entities: { @@ -25,28 +23,41 @@ export const authConfig = { successRedirectPath: "/", } -export async function findUserBy(where: Prisma.UserWhereUniqueInput): Promise { - return prisma.user.findUnique({ where }); +export async function findAuthWithUserBy(where: Prisma.AuthWhereInput) { + return prisma.auth.findFirst({ where, include: { user: true }}); } -export async function createUser(data: Prisma.UserCreateInput): Promise { +export async function createAuthWithUser(data: Prisma.AuthCreateInput, additionalFields?: PossibleAdditionalSignupFields) { try { - return await prisma.user.create({ data }) + return await prisma.auth.create({ + data: { + ...data, + user: { + create: { + // Using any here to prevent type errors when additionalFields are not + // defined. We want Prisma to throw an error in that case. + ...(additionalFields ?? {} as any), + } + } + } + }) } catch (e) { rethrowPossiblePrismaError(e); } } -export async function deleteUser(user: User): Promise { +export async function deleteAuth(auth: Auth) { try { - return await prisma.user.delete({ where: { id: user.id } }) + return await prisma.auth.delete({ where: { id: auth.id } }) } catch (e) { rethrowPossiblePrismaError(e); } } -export async function createAuthToken(user: User): Promise { - return sign(user.id); +export async function createAuthToken( + auth: Auth & { user: User } +): Promise { + return sign(auth.user.id); } export async function verifyToken(token: string): Promise<{ id: any }> { 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 11c884307d..e4ae52bdf6 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 @@ -47,7 +47,18 @@ export async function getUserFromToken(token) { } } - const user = await prisma.user.findUnique({ where: { id: userIdFromToken } }) + // TODO: this might not be the best thing to do, each request makes a db call + const user = await prisma.user + .findUnique({ + where: { id: userIdFromToken }, + include: { + auth: { + include: { + providers: true + } + } + } + }) if (!user) { throwInvalidCredentialsError() } @@ -56,9 +67,17 @@ export async function getUserFromToken(token) { // 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 { password, ...userView } = user - - return userView + const { + auth: { password, ...authFields }, + ...fields + } = user + + return { + ...fields, + auth: { + ...authFields + } + } } const SP = new SecurePassword() diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js index 44eff27892..fd83cf442c 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js @@ -1,26 +1,40 @@ import { hashPassword } from '../auth.js' import { PASSWORD_FIELD } from '../../auth/validation.js' +let isPasswordHashingEnabled = true + +export async function withDisabledPasswordHashing(action) { + isPasswordHashingEnabled = false + await action() + isPasswordHashingEnabled = true +} + // Make sure password is always hashed before storing to the database. const registerPasswordHashing = (prismaClient) => { prismaClient.$use(async (params, next) => { - if (params.model === 'User') { - if (['create', 'update', 'updateMany'].includes(params.action)) { - if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { - params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) - } - } else if (params.action === 'upsert') { - if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { - params.args.create[PASSWORD_FIELD] = - await hashPassword(params.args.create[PASSWORD_FIELD]) - } - if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { - params.args.update[PASSWORD_FIELD] = - await hashPassword(params.args.update[PASSWORD_FIELD]) - } - } + if (params.model !== 'Auth') { + return next(params) + } + + if (!isPasswordHashingEnabled) { + return next(params) } + if (['create', 'update', 'updateMany'].includes(params.action)) { + if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { + params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) + } + } else if (params.action === 'upsert') { + if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { + params.args.create[PASSWORD_FIELD] = + await hashPassword(params.args.create[PASSWORD_FIELD]) + } + if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { + params.args.update[PASSWORD_FIELD] = + await hashPassword(params.args.update[PASSWORD_FIELD]) + } + } + return next(params) }) } 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 0306683e35..e2de0a4a3a 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 @@ -5,9 +5,7 @@ import type { AuthenticatedQuery, _Task, } from "../_types"; -import type { - Prisma, -} from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import { Payload } from "../_types/serialization.js"; import type { Task, diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts index c31c1b7724..b53e5fe0a8 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts @@ -8,6 +8,7 @@ export { type User, type SocialLogin, type Task, + type Auth, } from "@prisma/client" export type Entity = diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts index 6435f6bd35..898615eb4a 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts @@ -8,6 +8,7 @@ export type { User, SocialLogin, Task, + type Auth, } from '@prisma/client' export type Entity = diff --git a/waspc/headless-test/examples/todoApp/migrations/20231127133821_use_new_auth/migration.sql b/waspc/headless-test/examples/todoApp/migrations/20231127133821_use_new_auth/migration.sql new file mode 100644 index 0000000000..342e30f631 --- /dev/null +++ b/waspc/headless-test/examples/todoApp/migrations/20231127133821_use_new_auth/migration.sql @@ -0,0 +1,65 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `emailVerificationSentAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `isEmailVerified` on the `User` table. All the data in the column will be lost. + - 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 `passwordResetSentAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the `SocialLogin` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "SocialLogin" DROP CONSTRAINT "SocialLogin_userId_fkey"; + +-- DropIndex +DROP INDEX "User_email_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "email", +DROP COLUMN "emailVerificationSentAt", +DROP COLUMN "isEmailVerified", +DROP COLUMN "password", +DROP COLUMN "passwordResetSentAt"; + +-- DropTable +DROP TABLE "SocialLogin"; + +-- 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/headless-test/examples/todoApp/src/server/apis.ts b/waspc/headless-test/examples/todoApp/src/server/apis.ts index 66a06375c6..a5d6ca47d3 100644 --- a/waspc/headless-test/examples/todoApp/src/server/apis.ts +++ b/waspc/headless-test/examples/todoApp/src/server/apis.ts @@ -3,13 +3,13 @@ import express from 'express' import { MiddlewareConfigFn } from '@wasp/middleware' export const fooBar: FooBar = (_req, res, context) => { - res.json({ msg: `Hello, ${context?.user?.email}!` }) + res.json({ msg: `Hello, ${context?.user?.auth?.email}!` }) } export const fooBarMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => { // console.log('fooBarMiddlewareFn: Adding custom middleware for route.') - const customMiddleware : express.RequestHandler = (_req, _res, next) => { + const customMiddleware: express.RequestHandler = (_req, _res, next) => { console.log('fooBarMiddlewareFn: custom route middleware') next() } @@ -23,15 +23,17 @@ export const barBaz: BarBaz = (_req, res, _context) => { res.json({ msg: `Hello, stranger!` }) } -export const barNamespaceMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => { +export const barNamespaceMiddlewareFn: MiddlewareConfigFn = ( + middlewareConfig +) => { console.log('barNamespaceMiddlewareFn: Ignoring all default middleware.') - middlewareConfig.set('custom.apiNamespace', - (req, _res, next) => { - console.log(`barNamespaceMiddlewareFn: custom middleware (path: ${req.path})`) - next() - } - ) + middlewareConfig.set('custom.apiNamespace', (req, _res, next) => { + console.log( + `barNamespaceMiddlewareFn: custom middleware (path: ${req.path})` + ) + next() + }) return middlewareConfig } @@ -40,9 +42,11 @@ export const webhookCallback: WebhookCallback = (req, res, _context) => { res.json({ msg: req.body.length }) } -export const webhookCallbackMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => { +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: '*/*' })) diff --git a/waspc/headless-test/examples/todoApp/todoApp.wasp b/waspc/headless-test/examples/todoApp/todoApp.wasp index 50d7b7a426..8e3746ec1f 100644 --- a/waspc/headless-test/examples/todoApp/todoApp.wasp +++ b/waspc/headless-test/examples/todoApp/todoApp.wasp @@ -9,7 +9,6 @@ app todoApp { ], auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { email: { fromField: { @@ -56,28 +55,10 @@ app todoApp { entity User {=psl id Int @id @default(autoincrement()) - // Email auth - email String? @unique - password String? - isEmailVerified Boolean @default(false) - emailVerificationSentAt DateTime? - passwordResetSentAt DateTime? - // Social login - externalAuthAssociations SocialLogin[] // Business logic tasks Task[] 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=} - entity Task {=psl id Int @id @default(autoincrement()) description String diff --git a/waspc/headless-test/start.js b/waspc/headless-test/start.js index 58585e9a65..1d9fa665cf 100644 --- a/waspc/headless-test/start.js +++ b/waspc/headless-test/start.js @@ -1,24 +1,25 @@ -const cp = require('child_process'); -const readline = require('linebyline'); +const cp = require("child_process"); +const readline = require("linebyline"); function spawn(name, cmd, args, done) { const spawnOptions = { detached: true, + stdio: ["ignore", "pipe", "pipe"], }; const proc = cp.spawn(cmd, args, spawnOptions); // We close stdin stream on the new process because otherwise the start-app // process hangs. // See https://github.com/wasp-lang/wasp/pull/1218#issuecomment-1599098272. - proc.stdin.destroy(); + // proc.stdin.destroy(); - readline(proc.stdout).on('line', data => { + readline(proc.stdout).on("line", (data) => { console.log(`\x1b[0m\x1b[33m[${name}][out]\x1b[0m ${data}`); }); - readline(proc.stderr).on('line', data => { + readline(proc.stderr).on("line", (data) => { console.log(`\x1b[0m\x1b[33m[${name}][err]\x1b[0m ${data}`); }); - proc.on('exit', done); + proc.on("exit", done); } // Exit if either child fails @@ -26,6 +27,6 @@ const cb = (code) => { if (code !== 0) { process.exit(code); } -} -spawn('app', 'npm', ['run', 'example-app:start-app'], cb); -spawn('db', 'npm', ['run', 'example-app:start-db'], cb) +}; +spawn("app", "npm", ["run", "example-app:start-app"], cb); +spawn("db", "npm", ["run", "example-app:start-db"], cb); diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index 2e3b11518a..2fb7c6dfc3 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -12,7 +12,7 @@ where import Control.Monad (unless) import Data.List (find, group, groupBy, intercalate, sort, sortBy) -import Data.Maybe (fromJust, isJust) +import Data.Maybe (fromJust, isJust, isNothing) import Text.Read (readMaybe) import Text.Regex.TDFA ((=~)) import Wasp.AppSpec (AppSpec) @@ -52,6 +52,7 @@ validateAppSpec spec = concat [ validateWasp spec, validateAppAuthIsSetIfAnyPageRequiresAuth spec, + validateUserEntity spec, validateOnlyEmailOrUsernameAndPasswordAuthIsUsed spec, validateEmailSenderIsDefinedIfEmailAuthIsUsed spec, validateDbIsPostgresIfPgBossUsed spec, @@ -115,6 +116,19 @@ validateWaspVersion specWaspVersionStr = eitherUnitToErrorList $ do eitherUnitToErrorList (Left e) = [e] eitherUnitToErrorList (Right ()) = [] +validateUserEntity :: AppSpec -> [ValidationError] +validateUserEntity spec = + case App.auth (snd $ getApp spec) of + Nothing -> [] + Just auth -> + [ GenericValidationError $ "Entity '" ++ userEntityName ++ "' (referenced by app.auth.userEntity) must have an ID field (specified with the '@id' attribute)" + | isNothing idFieldType + ] + where + idFieldType = Entity.getIdField userEntity + + (userEntityName, userEntity) = AS.resolveRef spec (Auth.userEntity auth) + validateAppAuthIsSetIfAnyPageRequiresAuth :: AppSpec -> [ValidationError] validateAppAuthIsSetIfAnyPageRequiresAuth spec = [ GenericValidationError diff --git a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs index 6fca5245a0..edfabeb479 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs @@ -39,7 +39,7 @@ injectAuth :: Maybe (String, AS.Entity.Entity) -> [(String, AS.Entity.Entity)] - injectAuth Nothing entities = return entities injectAuth (Just (userEntityName, userEntity)) entities = do userEntityIdType <- getUserEntityIdType userEntity - authEntity <- makeAuthEntity userEntityIdType userEntityName + authEntity <- makeAuthEntity userEntityIdType userEntityName userEntity providerEntity <- makeProviderEntity let entitiesWithAuth = injectAuthIntoUserEntity userEntityName entities return $ entitiesWithAuth ++ [authEntity, providerEntity] @@ -71,13 +71,11 @@ makeProviderEntity = case parsePslBody providerEntityPslBody of authEntityNameText = T.pack authEntityName authFieldOnProviderEntityNameText = T.pack authFieldOnProviderEntityName -makeAuthEntity :: String -> String -> Generator (String, AS.Entity.Entity) -makeAuthEntity userEntityIdType userEntityName = case parsePslBody authEntityPslBody of +makeAuthEntity :: String -> String -> AS.Entity.Entity -> Generator (String, AS.Entity.Entity) +makeAuthEntity userEntityIdType userEntityName userEntity = case parsePslBody authEntityPslBody of Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err Right pslBody -> return (authEntityName, AS.Entity.makeEntity pslBody) where - -- TODO(miho): decide if we want to switch between fields for username and email - -- based auth. It's much simpler to just have everything and let some fields be null. authEntityPslBody = T.unpack [trimming| @@ -89,7 +87,7 @@ makeAuthEntity userEntityIdType userEntityName = case parsePslBody authEntityPsl emailVerificationSentAt DateTime? passwordResetSentAt DateTime? userId ${userEntityIdTypeText}? @unique - ${userFieldOnAuthEntityNameText} ${userEntityNameText}? @relation(fields: [userId], references: [id], onDelete: Cascade) + ${userFieldOnAuthEntityNameText} ${userEntityNameText}? @relation(fields: [userId], references: [${userEntityIdFieldName}], onDelete: Cascade) ${providersFieldOnAuthEntityNameText} ${providerEntityNameText}[] |] @@ -99,6 +97,7 @@ makeAuthEntity userEntityIdType userEntityName = case parsePslBody authEntityPsl userFieldOnAuthEntityNameText = T.pack userFieldOnAuthEntityName providerEntityNameText = T.pack providerEntityName providersFieldOnAuthEntityNameText = T.pack providersFieldOnAuthEntityName + userEntityIdFieldName = T.pack $ AS.Entity.getIdField userEntity & fromJust & Psl.Model.Field._name injectAuthIntoUserEntity :: String -> [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)] injectAuthIntoUserEntity userEntityName entities = diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index a3a048b6c7..29863a02ce 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -21,7 +21,6 @@ import qualified Wasp.AppSpec.Page as AS.Page import qualified Wasp.AppSpec.Route as AS.Route import qualified Wasp.AppSpec.Valid as ASV import qualified Wasp.Psl.Ast.Model as PslM -import qualified Wasp.Psl.Ast.Model as PslModel import qualified Wasp.SemanticVersion as SV import qualified Wasp.Version as WV @@ -88,33 +87,10 @@ spec_AppSpecValid = do describe "auth-related validation" $ do let userEntityName = "User" - let validUserField = - PslModel.Field - { PslModel._name = "username", - PslModel._type = PslModel.String, - PslModel._attrs = - [ PslModel.Attribute - { PslModel._attrName = "unique", - PslModel._attrArgs = [] - } - ], - PslModel._typeModifiers = [] - } let validUserEntity = AS.Entity.makeEntity ( PslM.Body - [ PslM.ElementField validUserField, - PslM.ElementField $ makeBasicPslField "password" PslM.String - ] - ) - let validUserEntityForEmailAuth = - AS.Entity.makeEntity - ( PslM.Body - [ PslM.ElementField $ makePslField "email" PslM.String True, - PslM.ElementField $ makePslField "password" PslM.String True, - PslM.ElementField $ makePslField "isEmailVerified" PslM.Boolean False, - PslM.ElementField $ makePslField "emailVerificationSentAt" PslM.DateTime True, - PslM.ElementField $ makePslField "passwordResetSentAt" PslM.DateTime True + [ PslM.ElementField $ makeIdField "id" PslM.String ] ) let validAppAuth = @@ -129,7 +105,8 @@ spec_AppSpecValid = do AS.Auth.email = Nothing }, AS.Auth.onAuthFailedRedirectTo = "/", - AS.Auth.onAuthSucceededRedirectTo = Nothing + AS.Auth.onAuthSucceededRedirectTo = Nothing, + AS.Auth.signup = Nothing } describe "should validate that when a page has authRequired, app.auth is also set." $ do @@ -155,9 +132,8 @@ spec_AppSpecValid = do "Expected app.auth to be defined since there are Pages with authRequired set to true." ] it "contains expected fields" $ do - ASV.doesUserEntityContainField (makeSpec Nothing Nothing) "password" `shouldBe` Nothing - ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "username" `shouldBe` Just True - ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "password" `shouldBe` Just True + ASV.doesUserEntityContainField (makeSpec Nothing Nothing) "id" `shouldBe` Nothing + ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "id" `shouldBe` Just True ASV.doesUserEntityContainField (makeSpec (Just validAppAuth) Nothing) "missing" `shouldBe` Just False describe "should validate that UsernameAndPassword and Email auth cannot used at the same time" $ do @@ -173,7 +149,8 @@ spec_AppSpecValid = do AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, AS.Auth.externalAuthEntity = Nothing, AS.Auth.onAuthFailedRedirectTo = "/", - AS.Auth.onAuthSucceededRedirectTo = Nothing + AS.Auth.onAuthSucceededRedirectTo = Nothing, + AS.Auth.signup = Nothing }, AS.App.emailSender = Just @@ -212,7 +189,7 @@ spec_AppSpecValid = do 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 = Nothing, google = Nothing, gitHub = Nothing, email = Just emailAuthConfig}) validUserEntityForEmailAuth) `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) @@ -230,21 +207,7 @@ spec_AppSpecValid = do let invalidUserEntity = AS.Entity.makeEntity ( PslM.Body - [ PslM.ElementField $ makeBasicPslField "email" PslM.String, - PslM.ElementField $ makeBasicPslField "password" PslM.String - ] - ) - let invalidUserEntity2 = - AS.Entity.makeEntity - ( PslM.Body - [PslM.ElementField validUserField] - ) - let invalidUserEntity3 = - AS.Entity.makeEntity - ( PslM.Body - [ PslM.ElementField $ makeBasicPslField "username" PslM.String, - PslM.ElementField $ makeBasicPslField "password" PslM.String - ] + [] ) it "returns no error if app.auth is not set, regardless of shape of user entity" $ do @@ -255,27 +218,21 @@ spec_AppSpecValid = do it "returns an error if app.auth is set and user entity is of invalid shape" $ do ASV.validateAppSpec (makeSpec (Just validAppAuth) invalidUserEntity) `shouldBe` [ ASV.GenericValidationError - "Entity 'User' (referenced by app.auth.userEntity) must have field 'username' of type 'String'." - ] - ASV.validateAppSpec (makeSpec (Just validAppAuth) invalidUserEntity2) - `shouldBe` [ ASV.GenericValidationError - "Entity 'User' (referenced by app.auth.userEntity) must have field 'password' of type 'String'." - ] - ASV.validateAppSpec (makeSpec (Just validAppAuth) invalidUserEntity3) - `shouldBe` [ ASV.GenericValidationError - "The field 'username' on entity 'User' (referenced by app.auth.userEntity) must be marked with the '@unique' attribute." + "Entity 'User' (referenced by app.auth.userEntity) must have an ID field (specified with the '@id' attribute)" ] where - makeBasicPslField name typ = makePslField name typ False - - makePslField name typ isOptional = + makeIdField name typ = PslM.Field { PslM._name = name, PslM._type = typ, PslM._typeModifiers = - [ PslM.Optional | isOptional - ], - PslM._attrs = [] + [], + PslM._attrs = + [ PslM.Attribute + { PslM._attrName = "id", + PslM._attrArgs = [] + } + ] } basicApp = From 83ad55573f413ed115d75471eb033906c2ecc7d4 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 27 Nov 2023 15:27:50 +0100 Subject: [PATCH 14/62] Cleanup Signed-off-by: Mihovil Ilakovac --- .../src/auth/providers/oauth/createRouter.ts | 1 - .../waspComplexTest/.wasp/out/.waspchecksums | 2 +- .../src/auth/providers/oauth/createRouter.ts | 1 - waspc/headless-test/start.js | 19 +++++++++---------- waspc/src/Wasp/Generator/DbGenerator/Auth.hs | 1 + 5 files changed, 11 insertions(+), 13 deletions(-) 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 f490d36265..1b494bca5b 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 @@ -89,7 +89,6 @@ async function findOrCreateAuthByAuthProvider( } } - // TODO(miho): decide if we want to keep the custom data on User or Auth? const auth = await createAuthWithUser(authAndProviderData, userFields) // NOTE: we are fetching the auth again becuase it incldues nested user return findAuthWithUserBy({ id: auth.id }); 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 17d4041167..c54c330771 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 @@ -151,7 +151,7 @@ "file", "server/src/auth/providers/oauth/createRouter.ts" ], - "c14b5fef9b96921081ab5a8412847ea2bf4dc00f96850369e691c9c1955502a8" + "ff80987461ff6a305709b2a6d8b97be3ad2090216525a0e979dd2f2858e43985" ], [ [ 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 5dd4f8bdea..f2fe1e905a 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 @@ -88,7 +88,6 @@ async function findOrCreateAuthByAuthProvider( } } - // TODO(miho): decide if we want to keep the custom data on User or Auth? const auth = await createAuthWithUser(authAndProviderData, userFields) // NOTE: we are fetching the auth again becuase it incldues nested user return findAuthWithUserBy({ id: auth.id }); diff --git a/waspc/headless-test/start.js b/waspc/headless-test/start.js index 1d9fa665cf..58585e9a65 100644 --- a/waspc/headless-test/start.js +++ b/waspc/headless-test/start.js @@ -1,25 +1,24 @@ -const cp = require("child_process"); -const readline = require("linebyline"); +const cp = require('child_process'); +const readline = require('linebyline'); function spawn(name, cmd, args, done) { const spawnOptions = { detached: true, - stdio: ["ignore", "pipe", "pipe"], }; const proc = cp.spawn(cmd, args, spawnOptions); // We close stdin stream on the new process because otherwise the start-app // process hangs. // See https://github.com/wasp-lang/wasp/pull/1218#issuecomment-1599098272. - // proc.stdin.destroy(); + proc.stdin.destroy(); - readline(proc.stdout).on("line", (data) => { + readline(proc.stdout).on('line', data => { console.log(`\x1b[0m\x1b[33m[${name}][out]\x1b[0m ${data}`); }); - readline(proc.stderr).on("line", (data) => { + readline(proc.stderr).on('line', data => { console.log(`\x1b[0m\x1b[33m[${name}][err]\x1b[0m ${data}`); }); - proc.on("exit", done); + proc.on('exit', done); } // Exit if either child fails @@ -27,6 +26,6 @@ const cb = (code) => { if (code !== 0) { process.exit(code); } -}; -spawn("app", "npm", ["run", "example-app:start-app"], cb); -spawn("db", "npm", ["run", "example-app:start-db"], cb); +} +spawn('app', 'npm', ['run', 'example-app:start-app'], cb); +spawn('db', 'npm', ['run', 'example-app:start-db'], cb) diff --git a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs index edfabeb479..eba2b0fc9d 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Auth.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Auth.hs @@ -97,6 +97,7 @@ makeAuthEntity userEntityIdType userEntityName userEntity = case parsePslBody au userFieldOnAuthEntityNameText = T.pack userFieldOnAuthEntityName providerEntityNameText = T.pack providerEntityName providersFieldOnAuthEntityNameText = T.pack providersFieldOnAuthEntityName + -- We validated the AppSpec so we are sure that the user entity has an id field. userEntityIdFieldName = T.pack $ AS.Entity.getIdField userEntity & fromJust & Psl.Model.Field._name injectAuthIntoUserEntity :: String -> [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)] From 8693c6979d94a00f9c37170c70853aa59215a876 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Tue, 28 Nov 2023 09:35:36 +0100 Subject: [PATCH 15/62] Fixes tests --- .../templates/react-app/src/entities/index.ts | 2 +- .../waspComplexTest/.wasp/out/.waspchecksums | 2 +- .../.wasp/out/web-app/src/entities/index.ts | 2 +- .../examples/todoApp/src/client/Todo.test.tsx | 33 ++++++++++++------- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/waspc/data/Generator/templates/react-app/src/entities/index.ts b/waspc/data/Generator/templates/react-app/src/entities/index.ts index bcf115a85f..e54f1a1643 100644 --- a/waspc/data/Generator/templates/react-app/src/entities/index.ts +++ b/waspc/data/Generator/templates/react-app/src/entities/index.ts @@ -10,7 +10,7 @@ export type { {= name =}, {=/ entities =} {=# isAuthEnabled =} - type {= authEntityName =}, + {= authEntityName =}, {=/ isAuthEnabled =} } from '@prisma/client' 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 c54c330771..6fe9789908 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 @@ -774,7 +774,7 @@ "file", "web-app/src/entities/index.ts" ], - "f05a58eef62f55439059a21159b150bd1fdf8f581df832ffcdbc73cf13439ad3" + "ba9105522cf140b8b7e276e706868f337fba58ffe935b83b8dff2f0226cb9b0d" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts index 898615eb4a..c2bff98d28 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts @@ -8,7 +8,7 @@ export type { User, SocialLogin, Task, - type Auth, + Auth, } from '@prisma/client' export type Entity = diff --git a/waspc/examples/todoApp/src/client/Todo.test.tsx b/waspc/examples/todoApp/src/client/Todo.test.tsx index 9746808f9c..2966468f85 100644 --- a/waspc/examples/todoApp/src/client/Todo.test.tsx +++ b/waspc/examples/todoApp/src/client/Todo.test.tsx @@ -15,12 +15,14 @@ test('areThereAnyTasks', () => { const { mockQuery } = mockServer() -const mockTasks = [{ - id: 1, - description: 'test todo 1', - isDone: true, - userId: 1 -}] +const mockTasks = [ + { + id: 1, + description: 'test todo 1', + isDone: true, + userId: 1, + }, +] test('handles mock data', async () => { mockQuery(getTasks, mockTasks) @@ -36,10 +38,15 @@ test('handles mock data', async () => { const mockUser = { id: 12, - email: 'elon@tesla.com', - isEmailVerified: false, - emailVerificationSentAt: null, - passwordResetSentAt: null, + auth: { + id: '123', + userId: 12, + username: null, + email: 'elon@tesla.com', + isEmailVerified: false, + emailVerificationSentAt: null, + passwordResetSentAt: null, + }, address: null, } satisfies User @@ -48,7 +55,11 @@ test('handles multiple mock data sources', async () => { mockQuery(getDate, new Date()) mockQuery(getTasks, mockTasks) - renderInContext() + renderInContext( + + + + ) await screen.findByText('elon@tesla.com') From e6f6a1752a49e0a7394687b806363fec5e6283ee Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Tue, 12 Dec 2023 15:49:49 +0100 Subject: [PATCH 16/62] Use JSON based auth model --- .../templates/react-app/src/entities/index.ts | 1 + .../server/src/auth/providers/email/login.ts | 12 +-- .../providers/email/requestPasswordReset.ts | 27 +++++-- .../src/auth/providers/email/resetPassword.ts | 17 +++-- .../server/src/auth/providers/email/signup.ts | 41 +++++++--- .../server/src/auth/providers/email/utils.ts | 42 +++-------- .../src/auth/providers/email/verifyEmail.ts | 12 ++- .../server/src/auth/providers/local/login.ts | 23 +++--- .../server/src/auth/providers/local/signup.ts | 15 +++- .../src/auth/providers/oauth/createRouter.ts | 25 ++++--- .../templates/server/src/auth/utils.ts | 21 +++++- .../templates/server/src/core/auth.js | 2 +- .../server/src/core/auth/prismaMiddleware.js | 75 ++++++++++++------- .../templates/server/src/dbClient.ts | 4 +- .../templates/server/src/entities/index.ts | 1 + waspc/examples/crud-testing/main.wasp | 6 +- .../20231212120120_inital/migration.sql | 24 ++++++ .../20231212132054_random/migration.sql | 24 ++++++ .../20231212132224_thrid/migration.sql | 13 ++++ .../crud-testing/src/client/MainPage.tsx | 11 ++- .../examples/crud-testing/src/server/auth.ts | 20 +++-- .../crud-testing/src/server/migrate.ts | 59 +++++++++------ .../examples/crud-testing/src/server/tasks.ts | 14 ++-- .../20231212135316_next/migration.sql | 44 +++++++++++ waspc/examples/todoApp/src/client/App.tsx | 9 ++- .../todoApp/src/client/pages/ProfilePage.tsx | 17 ++++- waspc/examples/todoApp/src/server/apis.ts | 2 +- .../todoApp/src/server/auth/google.js | 4 +- waspc/examples/todoApp/src/server/dbSeeds.ts | 18 +++-- waspc/examples/todoApp/todoApp.wasp | 8 +- .../todoApp/src/client/pages/ProfilePage.tsx | 16 ++-- waspc/src/Wasp/Generator/DbGenerator/Auth.hs | 52 +++++++------ waspc/src/Wasp/Generator/ServerGenerator.hs | 3 +- .../ServerGenerator/Auth/OAuthAuthG.hs | 6 +- .../Wasp/Generator/ServerGenerator/AuthG.hs | 10 ++- waspc/src/Wasp/Generator/WebAppGenerator.hs | 3 +- 36 files changed, 463 insertions(+), 218 deletions(-) create mode 100644 waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql create mode 100644 waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql create mode 100644 waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql create mode 100644 waspc/examples/todoApp/migrations/20231212135316_next/migration.sql diff --git a/waspc/data/Generator/templates/react-app/src/entities/index.ts b/waspc/data/Generator/templates/react-app/src/entities/index.ts index e54f1a1643..27d4ebc25e 100644 --- a/waspc/data/Generator/templates/react-app/src/entities/index.ts +++ b/waspc/data/Generator/templates/react-app/src/entities/index.ts @@ -11,6 +11,7 @@ export type { {=/ entities =} {=# isAuthEnabled =} {= authEntityName =}, + {= authIdentityEntityName =}, {=/ isAuthEnabled =} } from '@prisma/client' 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 ee901d7788..64b8a138aa 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,6 +1,6 @@ import { Request, Response } from 'express'; import { verifyPassword, throwInvalidCredentialsError } from "../../../core/auth.js"; -import { findAuthWithUserBy, createAuthToken } from "../../utils.js"; +import { findAuthIdentity, findAuthWithUserBy, createAuthToken } from "../../utils.js"; import { ensureValidEmail, ensurePasswordIsPresent } from "../../validation.js"; export function getLoginRoute({ @@ -17,19 +17,21 @@ export function getLoginRoute({ fields.email = fields.email.toLowerCase() - const auth = await findAuthWithUserBy({ email: fields.email }) - if (!auth) { + const authIdentity = await findAuthIdentity("email", fields.email) + if (!authIdentity) { throwInvalidCredentialsError() } - if (!auth.isEmailVerified && !allowUnverifiedLogin) { + const providerData = JSON.parse(authIdentity.providerData) + if (!providerData.isEmailVerified && !allowUnverifiedLogin) { throwInvalidCredentialsError() } try { - await verifyPassword(auth.password, fields.password); + await verifyPassword(providerData.password, fields.password); } catch(e) { throwInvalidCredentialsError() } + const auth = await findAuthWithUserBy({ id: authIdentity.authId }) const token = await createAuthToken(auth) return res.json({ token }) 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 ef772a99a8..2e080a25df 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 @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { + findAuthIdentity, findAuthWithUserBy, doFakeWork, } from "../../utils.js"; @@ -30,25 +31,35 @@ export function getRequestPasswordResetRoute({ args.email = args.email.toLowerCase(); - const auth = await findAuthWithUserBy({ email: args.email }); - + const authIdentity = await findAuthIdentity("email", args.email); + // User not found or not verified - don't leak information - if (!auth || !auth.isEmailVerified) { + if (!authIdentity) { + await doFakeWork(); + return res.json({ success: true }); + } + + const providerData = JSON.parse(authIdentity.providerData); + if (!providerData.isEmailVerified) { await doFakeWork(); return res.json({ success: true }); } - if (!isEmailResendAllowed(auth, 'passwordResetSentAt')) { + // TODO: check if the email was sent recently from providerData + if (!isEmailResendAllowed(providerData, 'passwordResetSentAt')) { return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); } - - const passwordResetLink = await createPasswordResetLink(auth, clientRoute); + + const passwordResetLink = await createPasswordResetLink({ + id: authIdentity.authId, + }, clientRoute); try { + const email = authIdentity.providerUserId await sendPasswordResetEmail( - auth.email, + email, { from: fromField, - to: auth.email, + to: email, ...getPasswordResetEmailContent({ passwordResetLink }), } ); 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 c2cd94cf42..cf07623608 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 @@ -1,8 +1,8 @@ import { Request, Response } from 'express'; -import { findAuthWithUserBy, verifyToken } from "../../utils.js"; -import { updateAuthPassword } from "./utils.js"; +import { findAuthIdentityByAuthId, updateAuthIdentityProviderData, verifyToken } from "../../utils.js"; import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js"; import { tokenVerificationErrors } from "./types.js"; +import { hashPassword } from '../../../core/auth.js'; export async function resetPassword( req: Request<{ token: string; password: string; }>, @@ -14,11 +14,18 @@ export async function resetPassword( const { token, password } = args; try { const { id: authId } = await verifyToken(token); - const auth = await findAuthWithUserBy({ id: authId }); - if (!auth) { + + const authIdentity = await findAuthIdentityByAuthId(authId); + if (!authIdentity) { return res.status(400).json({ success: false, message: 'Invalid token' }); } - await updateAuthPassword(authId, password); + + const providerData = JSON.parse(authIdentity.providerData); + const hashedPassword = await hashPassword(password); + await updateAuthIdentityProviderData(authId, { + ...providerData, + password: hashedPassword, + }); } catch (e) { const reason = e.name === tokenVerificationErrors.TokenExpiredError ? 'expired' 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 70d0167247..1464badd36 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 @@ -2,8 +2,9 @@ import { Request, Response } from 'express'; import { EmailFromField } from "../../../email/core/types.js"; import { createAuthWithUser, + findAuthIdentity, findAuthWithUserBy, - deleteAuth, + deleteUserByAuthId, doFakeWork, } from "../../utils.js"; import { @@ -14,6 +15,7 @@ import { import { ensureValidEmail, ensureValidPassword, ensurePasswordIsPresent } from "../../validation.js"; import { GetVerificationEmailContentFn } from './types.js'; import { validateAndGetAdditionalFields } from '../../utils.js' +import { hashPassword } from '../../../core/auth.js'; export function getSignupRoute({ fromField, @@ -33,24 +35,39 @@ export function getSignupRoute({ fields.email = fields.email.toLowerCase(); - const existingAuth = await findAuthWithUserBy({ email: fields.email }); + const existingAuthIdentity = await findAuthIdentity("email", fields.email); // User already exists and is verified - don't leak information - if (existingAuth && existingAuth.isEmailVerified) { - await doFakeWork(); - return res.json({ success: true }); - } else if (existingAuth && !existingAuth.isEmailVerified) { - if (!isEmailResendAllowed(existingAuth, 'emailVerificationSentAt')) { - return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); + + // TODO: check if the email is verified from providerData + if (existingAuthIdentity) { + const providerData = JSON.parse(existingAuthIdentity.providerData); + if (providerData.isEmailVerified) { + await doFakeWork(); + return res.json({ success: true }); + } else if (!providerData.isEmailVerified) { + if (!isEmailResendAllowed(providerData, 'emailVerificationSentAt')) { + return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); + } + // TODO: verify this is correct + await deleteUserByAuthId(existingAuthIdentity.authId); } - await deleteAuth(existingAuth); } const additionalFields = await validateAndGetAdditionalFields(fields); - + + const password = await hashPassword(fields.password); const auth = await createAuthWithUser( { - email: fields.email, - password: fields.password, + identities: { + create: { + providerName: "email", + providerUserId: fields.email, + providerData: JSON.stringify({ + password, + isEmailVerified: false, + }), + }, + } }, additionalFields, ); 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 a51df92358..5bd3ec7f34 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 @@ -2,8 +2,7 @@ import { sign } from '../../../core/auth.js' import { emailSender } from '../../../email/index.js'; import { Email } from '../../../email/core/types.js'; -import { rethrowPossiblePrismaError } from '../../utils.js' -import prisma from '../../../dbClient.js' +import { rethrowPossiblePrismaError, updateAuthIdentityProviderData, findAuthIdentity } from '../../utils.js'; import waspServerConfig from '../../../config.js'; import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../../../entities/index.js' @@ -14,28 +13,6 @@ type AuthWithId = { id: {= authEntityUpper =}Id, } -export async function updateAuthEmailVerification(authId: {= authEntityUpper =}Id) { - try { - await prisma.{= authEntityLower =}.update({ - where: { id: authId }, - data: { isEmailVerified: true }, - }) - } catch (e) { - rethrowPossiblePrismaError(e); - } -} - -export async function updateAuthPassword(authId: {= authEntityUpper =}Id, password: string) { - try { - await prisma.{= authEntityLower =}.update({ - where: { id: authId }, - data: { password }, - }) - } catch (e) { - rethrowPossiblePrismaError(e); - } -} - export async function createEmailVerificationLink(auth: AuthWithId, clientRoute: string) { const token = await createEmailVerificationToken(auth); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; @@ -77,10 +54,9 @@ async function sendEmailAndLogTimestamp( // so the user can't send multiple requests while // the email is being sent. try { - await prisma.{= authEntityLower =}.update({ - where: { email }, - data: { [field]: new Date() }, - }) + const authIdentity = await findAuthIdentity("email", email); + const providerData = JSON.parse(authIdentity.providerData); + await updateAuthIdentityProviderData(authIdentity.authId, { ...providerData, [field]: new Date() }); } catch (e) { rethrowPossiblePrismaError(e); } @@ -89,9 +65,11 @@ async function sendEmailAndLogTimestamp( }); } -export function isEmailResendAllowed( - auth: {= authEntityUpper =}, - field: 'emailVerificationSentAt' | 'passwordResetSentAt', +export function isEmailResendAllowed( + auth: { + [field in Field]: Date | null + }, + field: Field, resendInterval: number = 1000 * 60, ): boolean { const sentAt = auth[field]; @@ -99,6 +77,6 @@ export function isEmailResendAllowed( return true; } const now = new Date(); - const diff = now.getTime() - sentAt.getTime(); + const diff = now.getTime() - new Date(sentAt).getTime(); return diff > resendInterval; } 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 adc3309981..09e5508866 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 @@ -1,6 +1,5 @@ import { Request, Response } from 'express'; -import { updateAuthEmailVerification } from './utils.js'; -import { verifyToken } from '../../utils.js'; +import { verifyToken, findAuthIdentityByAuthId, updateAuthIdentityProviderData } from '../../utils.js'; import { tokenVerificationErrors } from './types.js'; export async function verifyEmail( @@ -10,7 +9,14 @@ export async function verifyEmail( try { const { token } = req.body; const { id: authId } = await verifyToken(token); - await updateAuthEmailVerification(authId); + + // TODO: update the auth identity JSON field "providerData" to set isEmailVerified=true + const authIdentity = await findAuthIdentityByAuthId(authId); + const providerData = JSON.parse(authIdentity.providerData); + await updateAuthIdentityProviderData(authId, { + ...providerData, + isEmailVerified: true, + }); } catch (e) { const reason = e.name === tokenVerificationErrors.TokenExpiredError ? 'expired' diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts index c3dcd5807f..6d89c5bee6 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts @@ -2,33 +2,36 @@ import { verifyPassword, throwInvalidCredentialsError } from '../../../core/auth.js' import { handleRejection } from '../../../utils.js' -import { findAuthWithUserBy, createAuthToken } from '../../utils.js' +import { findAuthIdentity, findAuthWithUserBy, createAuthToken } from '../../utils.js' import { ensureValidUsername, ensurePasswordIsPresent } from '../../validation.js' export default handleRejection(async (req, res) => { const fields = req.body || {} ensureValidArgs(fields) - const auth = await findAuthWithUserBy({ username: fields.username }) - if (!auth) { + const authIdentity = await findAuthIdentity('username', fields.username) + if (!authIdentity) { + console.log('authIdentity', authIdentity) throwInvalidCredentialsError() } try { + // TODO: use some JSON helper to parse the providerData + const providerData = JSON.parse(authIdentity.providerData) - await verifyPassword(auth.password, fields.password) + console.log('providerData', providerData) + + await verifyPassword(providerData.password, fields.password) } catch(e) { + console.log('e', e) throwInvalidCredentialsError() } - // Username & password valid - generate token. + const auth = await findAuthWithUserBy({ + id: authIdentity.authId + }) const token = await createAuthToken(auth) - // NOTE(matija): Possible option - instead of explicitly returning token here, - // we could add to response header 'Set-Cookie {token}' directive which would then make - // browser automatically save cookie with token. - // NOTE(shayne): Cross-domain cookies have serious limitations, which we recently explored. - return res.json({ token }) }) diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts index 78d0064333..0e090ef7e6 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts @@ -7,6 +7,7 @@ import { ensureValidPassword, } from '../../validation.js' import { validateAndGetAdditionalFields } from '../../utils.js' +import { hashPassword } from '../../../core/auth.js' export default handleRejection(async (req, res) => { const fields = req.body || {} @@ -14,10 +15,20 @@ export default handleRejection(async (req, res) => { const additionalFields = await validateAndGetAdditionalFields(fields) + // TODO: create auth identity with username (providerName="username" and providerUserId=username) + // TODO: set the hashed password in the JSON field "providerData" + const password = await hashPassword(fields.password) await createAuthWithUser( { - username: fields.username, - password: fields.password, + identities: { + create: { + providerName: 'username', + providerUserId: fields.username, + providerData: JSON.stringify({ + password, + }), + }, + }, }, // Using any here because we want to avoid TypeScript errors and // rely on Prisma to validate the data. 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 1b494bca5b..e138167c64 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 @@ -49,7 +49,7 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat const getUserFields = () => getUserFieldsFn ? getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) : Promise.resolve({}); // TODO: In the future we could make this configurable, possibly associating an external account // with the currently logged in account, or by some DB lookup. - const auth = await findOrCreateAuthByAuthProvider(provider.id, providerProfile.id, getUserFields); + const auth = await findOrCreateAuthByAuthIdentity(provider.id, providerProfile.id, getUserFields); const token = await sign(auth.{= userFieldOnAuthEntityName =}.id); res.json({ token }); @@ -59,16 +59,17 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat return router; } -async function findOrCreateAuthByAuthProvider( - provider: string, - providerId: string, +async function findOrCreateAuthByAuthIdentity( + providerName: string, + providerUserId: string, getUserFields: () => ReturnType, ) { // Attempt to find a User by an external auth association. - const authProvider = await prisma.{= authProviderEntityLower =}.findFirst({ - where: { provider, providerId }, + // TODO: find auth identity by provider and providerId + const authIdentity = await prisma.{= authIdentityEntityLower =}.findFirst({ + where: { providerName, providerUserId }, include: { - {= authFieldOnProviderEntityName =}: { + {= authFieldOnAuthIdentityEntityName =}: { include: { {= userFieldOnAuthEntityName =}: true } @@ -76,20 +77,22 @@ async function findOrCreateAuthByAuthProvider( } }) - if (authProvider) { - return authProvider.{= authFieldOnProviderEntityName =} + if (authIdentity) { + return authIdentity.{= authFieldOnAuthIdentityEntityName =} } // No external auth association linkage found. Create a new User using details from // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User. const userFields = await getUserFields() const authAndProviderData = { - {= providersFieldOnAuthEntityName =}: { - create: [{ provider, providerId }] + {= identitiesFieldOnAuthEntityName =}: { + create: [{ providerName, providerUserId }] } } + // TODO: create auth identity with provider and providerId const auth = await createAuthWithUser(authAndProviderData, userFields) // NOTE: we are fetching the auth again becuase it incldues nested user + // TODO: find auth identity by id return findAuthWithUserBy({ id: auth.id }); } diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index bd158aae76..fd4bf08aa7 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -32,6 +32,21 @@ export const authConfig = { successRedirectPath: "{= successRedirectPath =}", } +export async function findAuthIdentity(providerName: string, providerUserId: string) { + return prisma.{= authIdentityEntityLower =}.findUnique({ where: { providerName_providerUserId: { providerName, providerUserId } } }); +} + +export async function findAuthIdentityByAuthId(authId: string) { + return prisma.{= authIdentityEntityLower =}.findFirst({ where: { authId } }); +} + +export async function updateAuthIdentityProviderData(authId: string, providerData: Record) { + return prisma.{= authIdentityEntityLower =}.updateMany({ + where: { authId }, + data: { providerData: JSON.stringify(providerData) }, + }); +} + export async function findAuthWithUserBy(where: Prisma.{= authEntityUpper =}WhereInput) { return prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }}); } @@ -55,9 +70,11 @@ export async function createAuthWithUser(data: Prisma.{= authEntityUpper =}Creat } } -export async function deleteAuth(auth: {= authEntityUpper =}) { +export async function deleteUserByAuthId(authId: string) { try { - return await prisma.{= authEntityLower =}.delete({ where: { id: auth.id } }) + return await prisma.{= userEntityLower =}.deleteMany({ where: { auth: { + id: authId, + } } }) } catch (e) { rethrowPossiblePrismaError(e); } diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js index 5923c87a6e..82c4a30d7f 100644 --- a/waspc/data/Generator/templates/server/src/core/auth.js +++ b/waspc/data/Generator/templates/server/src/core/auth.js @@ -55,7 +55,7 @@ export async function getUserFromToken(token) { include: { {= authFieldOnUserEntityName =}: { include: { - {= providersFieldOnAuthEntityName =}: true + {= identitiesFieldOnAuthEntityName =}: true } } } diff --git a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js index 709715cdf3..625e491f38 100644 --- a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js +++ b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js @@ -10,36 +10,59 @@ export async function withDisabledPasswordHashing(action) { isPasswordHashingEnabled = true } +// async (params, next) => { +// if (params.model !== '{= authEntityUpper =}') { +// return next(params) +// } + +// if (!isPasswordHashingEnabled) { +// return next(params) +// } + +// if (['create', 'update', 'updateMany'].includes(params.action)) { +// if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { +// params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) +// } +// } else if (params.action === 'upsert') { +// if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { +// params.args.create[PASSWORD_FIELD] = +// await hashPassword(params.args.create[PASSWORD_FIELD]) +// } +// if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { +// params.args.update[PASSWORD_FIELD] = +// await hashPassword(params.args.update[PASSWORD_FIELD]) +// } +// } + +// return next(params) +// } + // Make sure password is always hashed before storing to the database. +// TODO: add support for JSON serialization and deserialization +// of providerData field. const registerPasswordHashing = (prismaClient) => { - prismaClient.$use(async (params, next) => { - if (params.model !== '{= authEntityUpper =}') { - return next(params) - } - - if (!isPasswordHashingEnabled) { - return next(params) - } - - if (['create', 'update', 'updateMany'].includes(params.action)) { - if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { - params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) - } - } else if (params.action === 'upsert') { - if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { - params.args.create[PASSWORD_FIELD] = - await hashPassword(params.args.create[PASSWORD_FIELD]) - } - if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { - params.args.update[PASSWORD_FIELD] = - await hashPassword(params.args.update[PASSWORD_FIELD]) - } - } - - return next(params) + return prismaClient.$extends({ + query: { + {= authEntityLower =}: { + async create({ model, operation, args, query }) { + return query(args); + }, + async update({ model, operation, args, query }) { + return query(args); + }, + async updateMany({ model, operation, args, query }) { + return query(args); + }, + async upsert({ model, operation, args, query }) { + return query(args); + }, + }, + }, }) } export const registerAuthMiddleware = (prismaClient) => { - registerPasswordHashing(prismaClient) + let extendedPrismaClient = registerPasswordHashing(prismaClient) + + return extendedPrismaClient } diff --git a/waspc/data/Generator/templates/server/src/dbClient.ts b/waspc/data/Generator/templates/server/src/dbClient.ts index fead2ab166..5d7537e484 100644 --- a/waspc/data/Generator/templates/server/src/dbClient.ts +++ b/waspc/data/Generator/templates/server/src/dbClient.ts @@ -6,10 +6,10 @@ import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js' {=/ isAuthEnabled =} const createDbClient = () => { - const prismaClient = new Prisma.PrismaClient() + let prismaClient = new Prisma.PrismaClient() {=# isAuthEnabled =} - registerAuthMiddleware(prismaClient) + prismaClient = registerAuthMiddleware(prismaClient) {=/ isAuthEnabled =} return prismaClient diff --git a/waspc/data/Generator/templates/server/src/entities/index.ts b/waspc/data/Generator/templates/server/src/entities/index.ts index d584ec7307..591309c09d 100644 --- a/waspc/data/Generator/templates/server/src/entities/index.ts +++ b/waspc/data/Generator/templates/server/src/entities/index.ts @@ -11,6 +11,7 @@ export { {=/ entities =} {=# isAuthEnabled =} type {= authEntityName =}, + type {= authIdentityEntityName =}, {=/ isAuthEnabled =} } from "@prisma/client" diff --git a/waspc/examples/crud-testing/main.wasp b/waspc/examples/crud-testing/main.wasp index 1fecd70275..2214dfd1ee 100644 --- a/waspc/examples/crud-testing/main.wasp +++ b/waspc/examples/crud-testing/main.wasp @@ -40,7 +40,7 @@ page LoginPage { route SignupRoute { path: "/signup", to: SignupPage } page SignupPage { - component: import { SignupPage } from "@client/CustomSignupPage.tsx", + component: import { SignupPage } from "@client/SignupPage.tsx", } route DetailRoute { path: "/:id/:something?", to: DetailPage } @@ -51,8 +51,8 @@ page DetailPage { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String + // username String @unique + // password String address String? tasks Task[] psl=} diff --git a/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql b/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql new file mode 100644 index 0000000000..dcc286e347 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql @@ -0,0 +1,24 @@ +/* + 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 new file mode 100644 index 0000000000..90ca6265e3 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql @@ -0,0 +1,24 @@ +/* + 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 new file mode 100644 index 0000000000..5ad32a2c57 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql @@ -0,0 +1,13 @@ +/* + 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/src/client/MainPage.tsx b/waspc/examples/crud-testing/src/client/MainPage.tsx index e46c018781..afdd5a25a3 100644 --- a/waspc/examples/crud-testing/src/client/MainPage.tsx +++ b/waspc/examples/crud-testing/src/client/MainPage.tsx @@ -5,7 +5,7 @@ import { Link, routes } from '@wasp/router' import logout from '@wasp/auth/logout' import { tasks as tasksCrud } from '@wasp/crud/tasks' -import { User } from '@wasp/entities' +import { User, AuthIdentity } from '@wasp/entities' const MainPage = ({ user }: { user: User }) => { const { data: tasks, isLoading } = tasksCrud.getAll.useQuery() @@ -62,6 +62,13 @@ const MainPage = ({ user }: { user: User }) => { } } + type User = NonNullable[number]['user'] + + function findUsername(user: User) { + return user.auth?.identities.find((i) => i.providerName === 'username') + ?.providerUserId + } + return (
@@ -98,7 +105,7 @@ const MainPage = ({ user }: { user: User }) => { {routes.DetailRoute.build({ params: { id: task.id, something: 'else' }, })}{' '} - by {task.user.auth?.username} + by {findUsername(task.user)}
diff --git a/waspc/examples/crud-testing/src/server/auth.ts b/waspc/examples/crud-testing/src/server/auth.ts index 3f5ad8bc7d..75115ed30f 100644 --- a/waspc/examples/crud-testing/src/server/auth.ts +++ b/waspc/examples/crud-testing/src/server/auth.ts @@ -6,6 +6,8 @@ import { ensureValidUsername, } from '@wasp/auth/validation.js' import prisma from '@wasp/dbClient.js' +import { CustomSignup } from '@wasp/actions/types' +import { hashPassword } from '@wasp/core/auth.js' export const fields = defineAdditionalSignupFields({ address: (data) => { @@ -22,9 +24,7 @@ export const fields = defineAdditionalSignupFields({ } return result.data }, -} as any) - -import { CustomSignup } from '@wasp/actions/types' +}) type CustomSignupInput = { username: string @@ -47,12 +47,20 @@ export const signup: CustomSignup< try { await prisma.auth.create({ data: { - username: args.username, - password: args.password, user: { create: { address: args.address, - } as any, + }, + }, + identities: { + create: { + providerName: 'username', + providerUserId: args.username, + // TODO: offer a providerData helper + providerData: JSON.stringify({ + password: hashPassword(args.password), + }), + }, }, }, }) diff --git a/waspc/examples/crud-testing/src/server/migrate.ts b/waspc/examples/crud-testing/src/server/migrate.ts index 67f511759f..fcffe1f2cd 100644 --- a/waspc/examples/crud-testing/src/server/migrate.ts +++ b/waspc/examples/crud-testing/src/server/migrate.ts @@ -4,27 +4,40 @@ import { withDisabledPasswordHashing } from '@wasp/core/auth/prismaMiddleware.js export async function migrateAuth(db: typeof prisma) { const users = await db.user.findMany() - for (let user of users) { - const auth = await db.auth.findUnique({ - where: { - username: user.username, - }, - }) - if (auth) { - continue - } - withDisabledPasswordHashing(() => - db.auth.create({ - data: { - username: user.username, - password: user.password, - user: { - connect: { - id: user.id, - }, - }, - }, - }) - ) - } + // TODO: the User model needs to have username & password and then not + // this is a tricky migration + + // for (let user of users) { + // const authIdentity = await db.authIdentity.findUnique({ + // where: { + // providerName_providerUserId: { + // providerName: 'username', + // providerUserId: user.username, + // }, + // }, + // }) + // if (authIdentity) { + // continue + // } + // withDisabledPasswordHashing(() => + // db.auth.create({ + // data: { + // user: { + // connect: { + // id: user.id, + // }, + // }, + // identities: { + // create: { + // providerName: 'username', + // providerUserId: user.username, + // providerData: JSON.stringify({ + // password: user.password, + // }), + // }, + // }, + // }, + // }) + // ) + // } } diff --git a/waspc/examples/crud-testing/src/server/tasks.ts b/waspc/examples/crud-testing/src/server/tasks.ts index 4b669639f6..6c5c820a01 100644 --- a/waspc/examples/crud-testing/src/server/tasks.ts +++ b/waspc/examples/crud-testing/src/server/tasks.ts @@ -24,13 +24,13 @@ export const getAllTasks = (async (args, context) => { id: true, title: true, user: { - // include: { - // auth: { - // select: { - // username: true, - // }, - // }, - // }, + include: { + auth: { + include: { + identities: true, + }, + }, + }, }, }, }) diff --git a/waspc/examples/todoApp/migrations/20231212135316_next/migration.sql b/waspc/examples/todoApp/migrations/20231212135316_next/migration.sql new file mode 100644 index 0000000000..8d5980ab4a --- /dev/null +++ b/waspc/examples/todoApp/migrations/20231212135316_next/migration.sql @@ -0,0 +1,44 @@ +/* + 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. + - 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"; + +-- 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"; + +-- 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/todoApp/src/client/App.tsx b/waspc/examples/todoApp/src/client/App.tsx index 4e51258eb8..33a193ba19 100644 --- a/waspc/examples/todoApp/src/client/App.tsx +++ b/waspc/examples/todoApp/src/client/App.tsx @@ -27,7 +27,14 @@ export function App({ children }: any) { {user && (
- Hello, {user.auth?.email} + Hello,{' '} + + { + (user.auth as any)?.identities.find( + (i) => i.providerName === 'email' + )?.providerUserId + } +
+
- ) -} + ); +}; -export default TopNavbar +export default TopNavbar; diff --git a/examples/todo-typescript/main.wasp b/examples/todo-typescript/main.wasp index bfb394c34e..cd84cad132 100644 --- a/examples/todo-typescript/main.wasp +++ b/examples/todo-typescript/main.wasp @@ -21,8 +21,6 @@ app TodoTypescript { // Then run `wasp db studio` to open Prisma Studio and view your db models entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String tasks Task[] psl=} diff --git a/examples/todo-typescript/migrations/20231012121747_initial/migration.sql b/examples/todo-typescript/migrations/20231012121747_initial/migration.sql deleted file mode 100644 index a94cea93d2..0000000000 --- a/examples/todo-typescript/migrations/20231012121747_initial/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "username" TEXT NOT NULL, - "password" TEXT NOT NULL -); - --- CreateTable -CREATE TABLE "Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false, - "userId" INTEGER NOT NULL, - CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql b/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql new file mode 100644 index 0000000000..0ea8e16da6 --- /dev/null +++ b/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "description" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER NOT NULL, + CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + PRIMARY KEY ("providerName", "providerUserId"), + CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/examples/todo-typescript/src/client/MainPage.tsx b/examples/todo-typescript/src/client/MainPage.tsx index b16d4242cf..6461071c71 100644 --- a/examples/todo-typescript/src/client/MainPage.tsx +++ b/examples/todo-typescript/src/client/MainPage.tsx @@ -9,7 +9,14 @@ import getTasks from "@wasp/queries/getTasks"; import createTask from "@wasp/actions/createTask"; import updateTask from "@wasp/actions/updateTask"; import deleteTasks from "@wasp/actions/deleteTasks"; -import type { Task, User } from "@wasp/entities"; +import type { Task } from "@wasp/entities"; +import type { User } from "@wasp/auth/types"; + +function getUsername(user: User) { + return user.auth?.identities.find( + (identity) => identity.providerName === "username" + )?.providerUserId; +} export const MainPage = ({ user }: { user: User }) => { const { data: tasks, isLoading, error } = useQuery(getTasks); @@ -24,7 +31,7 @@ export const MainPage = ({ user }: { user: User }) => { wasp logo {user && (

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

)} diff --git a/examples/tutorials/TodoApp/main.wasp b/examples/tutorials/TodoApp/main.wasp index 821016f186..7389a43656 100644 --- a/examples/tutorials/TodoApp/main.wasp +++ b/examples/tutorials/TodoApp/main.wasp @@ -36,8 +36,6 @@ page LoginPage { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String tasks Task[] psl=} diff --git a/examples/tutorials/TodoApp/migrations/20220113204751_init/migration.sql b/examples/tutorials/TodoApp/migrations/20220113204751_init/migration.sql deleted file mode 100644 index 7a12b36244..0000000000 --- a/examples/tutorials/TodoApp/migrations/20220113204751_init/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL -); - --- CreateTable -CREATE TABLE "Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false, - "userId" INTEGER, - FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE -); - --- CreateIndex -CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); diff --git a/examples/tutorials/TodoApp/migrations/20220818151104_rename_email_to_username/migration.sql b/examples/tutorials/TodoApp/migrations/20220818151104_rename_email_to_username/migration.sql deleted file mode 100644 index a3815987a5..0000000000 --- a/examples/tutorials/TodoApp/migrations/20220818151104_rename_email_to_username/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- DropIndex -DROP INDEX "User.email_unique"; - --- AlterTable -ALTER TABLE "User" -RENAME COLUMN "email" TO "username"; - --- CreateIndex -CREATE UNIQUE INDEX "User.username_unique" ON "User"("username"); diff --git a/examples/tutorials/TodoApp/migrations/20220818170255_getting_up_to_date/migration.sql b/examples/tutorials/TodoApp/migrations/20220818170255_getting_up_to_date/migration.sql deleted file mode 100644 index 215d194c3a..0000000000 --- a/examples/tutorials/TodoApp/migrations/20220818170255_getting_up_to_date/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- RedefineIndex -DROP INDEX "User.username_unique"; -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/tutorials/TodoApp/migrations/20231214131517_new_auth/migration.sql b/examples/tutorials/TodoApp/migrations/20231214131517_new_auth/migration.sql new file mode 100644 index 0000000000..533c854ea7 --- /dev/null +++ b/examples/tutorials/TodoApp/migrations/20231214131517_new_auth/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "description" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER, + CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + PRIMARY KEY ("providerName", "providerUserId"), + CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/examples/tutorials/TodoApp/migrations/20231214131607_remove_extra_auth_fields/migration.sql b/examples/tutorials/TodoApp/migrations/20231214131607_remove_extra_auth_fields/migration.sql new file mode 100644 index 0000000000..969799968d --- /dev/null +++ b/examples/tutorials/TodoApp/migrations/20231214131607_remove_extra_auth_fields/migration.sql @@ -0,0 +1,17 @@ +/* + 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. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); +INSERT INTO "new_User" ("id") SELECT "id" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/examples/tutorials/TodoAppTs/main.wasp b/examples/tutorials/TodoAppTs/main.wasp index ffe78756cb..a6c25b7e62 100644 --- a/examples/tutorials/TodoAppTs/main.wasp +++ b/examples/tutorials/TodoAppTs/main.wasp @@ -36,8 +36,6 @@ page LoginPage { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String tasks Task[] psl=} diff --git a/examples/tutorials/TodoAppTs/migrations/20231013154636_initial/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231013154636_initial/migration.sql deleted file mode 100644 index 32fc9d064f..0000000000 --- a/examples/tutorials/TodoAppTs/migrations/20231013154636_initial/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- CreateTable -CREATE TABLE "Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false -); diff --git a/examples/tutorials/TodoAppTs/migrations/20231013155651_user/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231013155651_user/migration.sql deleted file mode 100644 index bcc4dd7d09..0000000000 --- a/examples/tutorials/TodoAppTs/migrations/20231013155651_user/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "username" TEXT NOT NULL, - "password" TEXT NOT NULL -); - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/tutorials/TodoAppTs/migrations/20231013160107_users_tasks/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231013160107_users_tasks/migration.sql deleted file mode 100644 index c800c980bd..0000000000 --- a/examples/tutorials/TodoAppTs/migrations/20231013160107_users_tasks/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- RedefineTables -PRAGMA foreign_keys=OFF; -CREATE TABLE "new_Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false, - "userId" INTEGER, - CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE -); -INSERT INTO "new_Task" ("description", "id", "isDone") SELECT "description", "id", "isDone" FROM "Task"; -DROP TABLE "Task"; -ALTER TABLE "new_Task" RENAME TO "Task"; -PRAGMA foreign_key_check; -PRAGMA foreign_keys=ON; diff --git a/examples/tutorials/TodoAppTs/migrations/20231214131753_new_auth/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231214131753_new_auth/migration.sql new file mode 100644 index 0000000000..9e6e2345a3 --- /dev/null +++ b/examples/tutorials/TodoAppTs/migrations/20231214131753_new_auth/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "description" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER, + CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + PRIMARY KEY ("providerName", "providerUserId"), + CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/examples/waspello/main.wasp b/examples/waspello/main.wasp index 1bafe9b287..321d149480 100644 --- a/examples/waspello/main.wasp +++ b/examples/waspello/main.wasp @@ -11,7 +11,6 @@ app waspello { auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { usernameAndPassword: {}, google: {} @@ -47,21 +46,9 @@ page Login { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String + lists List[] cards Card[] - 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=} entity List {=psl diff --git a/examples/waspello/migrations/20211002184633_added_waspello_entities/migration.sql b/examples/waspello/migrations/20211002184633_added_waspello_entities/migration.sql deleted file mode 100644 index 203a88dfce..0000000000 --- a/examples/waspello/migrations/20211002184633_added_waspello_entities/migration.sql +++ /dev/null @@ -1,41 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "List" ( - "id" SERIAL NOT NULL, - "name" TEXT NOT NULL, - "pos" DOUBLE PRECISION NOT NULL, - "userId" INTEGER NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Card" ( - "id" SERIAL NOT NULL, - "title" TEXT NOT NULL, - "pos" DOUBLE PRECISION NOT NULL, - "listId" INTEGER NOT NULL, - "authorId" INTEGER NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); - --- AddForeignKey -ALTER TABLE "List" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/waspello/migrations/20220211211659_upgrade_prisma_to_3_9_1/migration.sql b/examples/waspello/migrations/20220211211659_upgrade_prisma_to_3_9_1/migration.sql deleted file mode 100644 index 893fbabf5a..0000000000 --- a/examples/waspello/migrations/20220211211659_upgrade_prisma_to_3_9_1/migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- DropForeignKey -ALTER TABLE "Card" DROP CONSTRAINT "Card_authorId_fkey"; - --- DropForeignKey -ALTER TABLE "Card" DROP CONSTRAINT "Card_listId_fkey"; - --- DropForeignKey -ALTER TABLE "List" DROP CONSTRAINT "List_userId_fkey"; - --- AddForeignKey -ALTER TABLE "List" ADD CONSTRAINT "List_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD CONSTRAINT "Card_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD CONSTRAINT "Card_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- RenameIndex -ALTER INDEX "User.email_unique" RENAME TO "User_email_key"; diff --git a/examples/waspello/migrations/20220818151104_rename_email_to_username/migration.sql b/examples/waspello/migrations/20220818151104_rename_email_to_username/migration.sql deleted file mode 100644 index 10182cf688..0000000000 --- a/examples/waspello/migrations/20220818151104_rename_email_to_username/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- DropIndex -DROP INDEX "User_email_key"; - --- AlterTable -ALTER TABLE "User" -RENAME COLUMN "email" TO "username"; - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/waspello/migrations/20221122161320_added_social_login_with_google/migration.sql b/examples/waspello/migrations/20221122161320_added_social_login_with_google/migration.sql deleted file mode 100644 index 0ae1754d7d..0000000000 --- a/examples/waspello/migrations/20221122161320_added_social_login_with_google/migration.sql +++ /dev/null @@ -1,16 +0,0 @@ --- CreateTable -CREATE TABLE "SocialLogin" ( - "id" SERIAL NOT NULL, - "provider" TEXT NOT NULL, - "providerId" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId"); - --- AddForeignKey -ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/waspello/migrations/20231214132020_new_auth/migration.sql b/examples/waspello/migrations/20231214132020_new_auth/migration.sql new file mode 100644 index 0000000000..1649460c27 --- /dev/null +++ b/examples/waspello/migrations/20231214132020_new_auth/migration.sql @@ -0,0 +1,63 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "List" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "pos" DOUBLE PRECISION NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "List_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Card" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "pos" DOUBLE PRECISION NOT NULL, + "listId" INTEGER NOT NULL, + "authorId" INTEGER NOT NULL, + + CONSTRAINT "Card_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") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- AddForeignKey +ALTER TABLE "List" ADD CONSTRAINT "List_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Card" ADD CONSTRAINT "Card_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Card" ADD CONSTRAINT "Card_authorId_fkey" FOREIGN KEY ("authorId") 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; diff --git a/examples/waspello/src/client/Navbar.jsx b/examples/waspello/src/client/Navbar.jsx index 8f109cf7d9..336807dee4 100644 --- a/examples/waspello/src/client/Navbar.jsx +++ b/examples/waspello/src/client/Navbar.jsx @@ -1,29 +1,32 @@ -import React from 'react' +import React from "react"; -import logout from '@wasp/auth/logout' +import logout from "@wasp/auth/logout"; -import logo from './waspello-logo-navbar.svg' -import './Navbar.css' +import logo from "./waspello-logo-navbar.svg"; +import "./Navbar.css"; const Navbar = ({ user }) => { - + const identifier = user.auth?.identities[0]?.providerUserId; return (
-
+
Home
Waspello -
+
- { user.username } + {identifier}  |  - +
- ) -} + ); +}; -export default Navbar +export default Navbar; diff --git a/examples/websockets-realtime-voting/main.wasp b/examples/websockets-realtime-voting/main.wasp index 31e069d9d9..4ce3bf7ebc 100644 --- a/examples/websockets-realtime-voting/main.wasp +++ b/examples/websockets-realtime-voting/main.wasp @@ -24,8 +24,6 @@ app whereDoWeEat { entity User {=psl id Int @id @default(autoincrement()) - // username String @unique - // password String psl=} route RootRoute { path: "/", to: MainPage } diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index ea860ec89e..aa8cd0e80e 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -3,9 +3,9 @@ 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 { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js' +import { sleep } from '../utils.js' import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../entities/index.js' -import { type Prisma } from '@prisma/client'; +import { Prisma } from '@prisma/client'; import { PASSWORD_FIELD, throwValidationError } from './validation.js' @@ -142,6 +142,33 @@ export function rethrowPossiblePrismaError(e: unknown): void { } } +const isPrismaError = (e: unknown) => { + return ( + e instanceof Prisma.PrismaClientKnownRequestError || + e instanceof Prisma.PrismaClientUnknownRequestError || + e instanceof Prisma.PrismaClientRustPanicError || + e instanceof Prisma.PrismaClientInitializationError || + e instanceof Prisma.PrismaClientValidationError + ) +} + +const prismaErrorToHttpError = (e: unknown) => { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + return 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. + console.error(e) + return new HttpError(422, 'Save failed', { + message: 'there was a database error' + }) + } + return new HttpError(500) +} + export async function validateAndGetAdditionalFields(data: { [key: string]: unknown }) { diff --git a/waspc/data/Generator/templates/server/src/utils.ts b/waspc/data/Generator/templates/server/src/utils.ts index 10ee679953..3e51f5a7f1 100644 --- a/waspc/data/Generator/templates/server/src/utils.ts +++ b/waspc/data/Generator/templates/server/src/utils.ts @@ -1,12 +1,14 @@ +{{={= =}=}} import { Request, Response, NextFunction } from 'express' -import { Prisma } from '@prisma/client' +import Prisma from '@prisma/client' import { readdir } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import HttpError from './core/HttpError.js' +{=# isAuthEnabled =} import { type SanitizedUser } from './_types/index.js' +{=/ isAuthEnabled =} /** * Decorator for async express middleware that handles promise rejections. @@ -15,15 +17,19 @@ import { type SanitizedUser } from './_types/index.js' * if given middleware returns promise, reject of that promise will be correctly handled, * meaning that error will be forwarded to next(). */ -type RequestWithUser = Request & { user?: SanitizedUser } +type RequestWithExtraFields = Request & { + {=# isAuthEnabled =} + user?: SanitizedUser + {=/ isAuthEnabled =} +} export const handleRejection = ( middleware: ( - req: RequestWithUser, + req: RequestWithExtraFields, res: Response, next: NextFunction - ) => Promise + ) => any ) => -async (req: RequestWithUser, res: Response, next: NextFunction) => { +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { try { await middleware(req, res, next) } catch (error) { @@ -31,33 +37,6 @@ async (req: RequestWithUser, res: Response, next: NextFunction) => { } } -export const isPrismaError = (e: unknown) => { - return ( - e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError - ) -} - -export const prismaErrorToHttpError = (e: unknown) => { - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return 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. - console.error(e) - return new HttpError(422, 'Save failed', { - message: 'there was a database error' - }) - } - return new HttpError(500) -} - export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) export function getDirFromFileUrl(fileUrl: string) { diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index 40f9792b7b..5a1805a617 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -207,13 +207,13 @@ genSrcDir :: AppSpec -> Generator [FileDraft] genSrcDir spec = sequence [ genFileCopy [relfile|app.js|], - genFileCopy [relfile|utils.ts|], genFileCopy [relfile|core/AuthError.js|], genFileCopy [relfile|core/HttpError.js|], genDbClient spec, genConfigFile spec, genServerJs spec ] + <++> genServerUtils spec <++> genRoutesDir spec <++> genTypesAndEntitiesDirs spec <++> genOperationsRoutes spec @@ -444,3 +444,8 @@ 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)] From 52f0881d624b141a8cadd3c5be133b4096947f2a Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 18 Dec 2023 14:47:20 +0100 Subject: [PATCH 28/62] PR comments --- .../server/src/auth/providers/email/login.ts | 6 +- .../providers/email/requestPasswordReset.ts | 8 +- .../src/auth/providers/email/resetPassword.ts | 12 +- .../server/src/auth/providers/email/signup.ts | 19 +-- .../server/src/auth/providers/email/utils.ts | 33 ++--- .../src/auth/providers/email/verifyEmail.ts | 12 +- .../server/src/auth/providers/local/login.ts | 6 +- .../src/auth/providers/oauth/createRouter.ts | 46 +++--- .../templates/server/src/auth/utils.ts | 135 +++++++++--------- .../templates/server/src/core/auth.js | 4 +- 10 files changed, 143 insertions(+), 138 deletions(-) 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 355ed72094..e729a663a2 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 @@ -4,7 +4,7 @@ import { findAuthIdentity, findAuthWithUserBy, createAuthToken, - deserializeProviderData, + deserializeAndSanitizeProviderData, } from "../../utils.js"; import { ensureValidEmail, ensurePasswordIsPresent } from "../../validation.js"; @@ -24,7 +24,7 @@ export function getLoginRoute({ if (!authIdentity) { throwInvalidCredentialsError() } - const providerData = deserializeProviderData<'email'>(authIdentity.providerData) + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData) if (!providerData.isEmailVerified && !allowUnverifiedLogin) { throwInvalidCredentialsError() } @@ -35,7 +35,7 @@ export function getLoginRoute({ } const auth = await findAuthWithUserBy({ id: authIdentity.authId }) - const token = await createAuthToken(auth) + const token = await createAuthToken(auth.userId) return res.json({ token }) }; 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 745c3f1700..7616f2ec95 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 @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { findAuthIdentity, doFakeWork, - deserializeProviderData, + deserializeAndSanitizeProviderData, } from "../../utils.js"; import { createPasswordResetLink, @@ -37,7 +37,7 @@ export function getRequestPasswordResetRoute({ return res.json({ success: true }); } - const providerData = deserializeProviderData<'email'>(authIdentity.providerData); + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); if (!providerData.isEmailVerified) { await doFakeWork(); return res.json({ success: true }); @@ -47,9 +47,7 @@ export function getRequestPasswordResetRoute({ return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); } - const passwordResetLink = await createPasswordResetLink({ - id: authIdentity.authId, - }, clientRoute); + const passwordResetLink = await createPasswordResetLink(args.email, clientRoute); try { const email = authIdentity.providerUserId await sendPasswordResetEmail( 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 6637b111b9..13c2735b28 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 @@ -1,9 +1,9 @@ import { Request, Response } from 'express'; import { - findAuthIdentityByAuthId, + findAuthIdentity, updateAuthIdentityProviderData, verifyToken, - deserializeProviderData, + deserializeAndSanitizeProviderData, } from "../../utils.js"; import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js"; import { tokenVerificationErrors } from "./types.js"; @@ -17,15 +17,15 @@ export async function resetPassword( const { token, password } = args; try { - const { id: authId } = await verifyToken(token); + const { id: providerUserId } = await verifyToken(token); - const authIdentity = await findAuthIdentityByAuthId(authId); + const authIdentity = await findAuthIdentity('email', providerUserId); if (!authIdentity) { return res.status(400).json({ success: false, message: 'Invalid token' }); } - const providerData = deserializeProviderData<'email'>(authIdentity.providerData); - await updateAuthIdentityProviderData(authId, providerData, { + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); + await updateAuthIdentityProviderData('email', providerUserId, providerData, { // The act of resetting the password verifies the email isEmailVerified: true, password, 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 62c6d02f11..815b710fd1 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 @@ -5,7 +5,7 @@ import { findAuthIdentity, deleteUserByAuthId, doFakeWork, - deserializeProviderData, + deserializeAndSanitizeProviderData, sanitizeAndSerializeProviderData, } from "../../utils.js"; import { @@ -35,18 +35,19 @@ export function getSignupRoute({ const existingAuthIdentity = await findAuthIdentity("email", fields.email); if (existingAuthIdentity) { - const providerData = deserializeProviderData<'email'>(existingAuthIdentity.providerData); + const providerData = deserializeAndSanitizeProviderData<'email'>(existingAuthIdentity.providerData); // User already exists and is verified - don't leak information if (providerData.isEmailVerified) { await doFakeWork(); return res.json({ success: true }); - } else if (!providerData.isEmailVerified) { - if (!isEmailResendAllowed(providerData, 'emailVerificationSentAt')) { - return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); - } - // User exists but is not verified - delete the user and create a new one - await deleteUserByAuthId(existingAuthIdentity.authId); } + + if (!isEmailResendAllowed(providerData, 'emailVerificationSentAt')) { + return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); + } + + // User exists but is not verified - delete the user and create a new one + await deleteUserByAuthId(existingAuthIdentity.authId); } const userFields = await validateAndGetAdditionalFields(fields); @@ -65,7 +66,7 @@ export function getSignupRoute({ userFields, ); - const verificationLink = await createEmailVerificationLink(auth, clientRoute); + const verificationLink = await createEmailVerificationLink(fields.email, clientRoute); try { await sendEmailVerificationEmail( fields.email, 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 9924206e81..2497f4b941 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 @@ -3,37 +3,30 @@ import { sign } from '../../../core/auth.js' import { emailSender } from '../../../email/index.js'; import { Email } from '../../../email/core/types.js'; import { - rethrowPossiblePrismaError, + rethrowPossibleAuthError, updateAuthIdentityProviderData, findAuthIdentity, - deserializeProviderData, + deserializeAndSanitizeProviderData, } from '../../utils.js'; import waspServerConfig from '../../../config.js'; import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../../../entities/index.js' -type {= authEntityUpper =}Id = {= authEntityUpper =}['id'] -type {= userEntityUpper =}Id = {= userEntityUpper =}['id'] - -type AuthWithId = { - id: {= authEntityUpper =}Id, -} - -export async function createEmailVerificationLink(auth: AuthWithId, clientRoute: string) { - const token = await createEmailVerificationToken(auth); +export async function createEmailVerificationLink(providerUserId: string, clientRoute: string) { + const token = await createEmailVerificationToken(providerUserId); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -export async function createPasswordResetLink(auth: AuthWithId, clientRoute: string) { - const token = await createPasswordResetToken(auth); +export async function createPasswordResetLink(providerUserId: string, clientRoute: string) { + const token = await createPasswordResetToken(providerUserId); return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; } -async function createEmailVerificationToken(auth: AuthWithId): Promise { - return sign(auth.id, { expiresIn: '30m' }); +async function createEmailVerificationToken(providerUserId: string): Promise { + return sign(providerUserId, { expiresIn: '30m' }); } -async function createPasswordResetToken(auth: AuthWithId): Promise { - return sign(auth.id, { expiresIn: '30m' }); +async function createPasswordResetToken(providerUserId: string): Promise { + return sign(providerUserId, { expiresIn: '30m' }); } export async function sendPasswordResetEmail( @@ -60,12 +53,12 @@ async function sendEmailAndLogTimestamp( // the email is being sent. try { const authIdentity = await findAuthIdentity("email", email); - const providerData = deserializeProviderData<'email'>(authIdentity.providerData); - await updateAuthIdentityProviderData<'email'>(authIdentity.authId, providerData, { + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); + await updateAuthIdentityProviderData<'email'>('email', email, providerData, { [field]: new Date() }); } catch (e) { - rethrowPossiblePrismaError(e); + rethrowPossibleAuthError(e); } emailSender.send(content).catch((e) => { console.error(`Failed to send email for ${field}`, e); 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 a8a18d0c0a..0e674def9b 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 @@ -1,9 +1,9 @@ import { Request, Response } from 'express'; import { verifyToken, - findAuthIdentityByAuthId, + findAuthIdentity, updateAuthIdentityProviderData, - deserializeProviderData, + deserializeAndSanitizeProviderData, } from '../../utils.js'; import { tokenVerificationErrors } from './types.js'; @@ -13,11 +13,11 @@ export async function verifyEmail( ): Promise> { try { const { token } = req.body; - const { id: authId } = await verifyToken(token); + const { id: providerUserId } = await verifyToken(token); - const authIdentity = await findAuthIdentityByAuthId(authId); - const providerData = deserializeProviderData<'email'>(authIdentity.providerData); - await updateAuthIdentityProviderData(authId, providerData, { + const authIdentity = await findAuthIdentity('email', providerUserId); + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); + await updateAuthIdentityProviderData('email', providerUserId, providerData, { isEmailVerified: true, }); } catch (e) { diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts index 4286e16a35..6d61520d45 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts @@ -6,7 +6,7 @@ import { findAuthIdentity, findAuthWithUserBy, createAuthToken, - deserializeProviderData, + deserializeAndSanitizeProviderData, } from '../../utils.js' import { ensureValidUsername, ensurePasswordIsPresent } from '../../validation.js' @@ -20,7 +20,7 @@ export default handleRejection(async (req, res) => { } try { - const providerData = deserializeProviderData<'username'>(authIdentity.providerData) + const providerData = deserializeAndSanitizeProviderData<'username'>(authIdentity.providerData) await verifyPassword(providerData.password, fields.password) } catch(e) { @@ -30,7 +30,7 @@ export default handleRejection(async (req, res) => { const auth = await findAuthWithUserBy({ id: authIdentity.authId }) - const token = await createAuthToken(auth) + const token = await createAuthToken(auth.userId) return res.json({ token }) }) 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 26d18150a5..4900b41b71 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 @@ -5,12 +5,13 @@ import passport from "passport" import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js' -import { sign } from '../../../core/auth.js' import { authConfig, contextWithUserEntity, createAuthWithUser, findAuthWithUserBy, + createAuthToken, + rethrowPossibleAuthError, } from "../../utils.js" import type { ProviderConfig, RequestWithWasp } from "../types.js" @@ -49,9 +50,10 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat const getUserFields = () => getUserFieldsFn ? getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) : Promise.resolve({}); // TODO: In the future we could make this configurable, possibly associating an external account // with the currently logged in account, or by some DB lookup. - const auth = await findOrCreateAuthByAuthIdentity(provider.id, providerProfile.id, getUserFields); + const userId = await getOrCreateUserIdByOAuthProvider(provider.id, providerProfile.id, getUserFields); + + const token = await createAuthToken(userId); - const token = await sign(auth.{= userFieldOnAuthEntityName =}.id); res.json({ token }); }) ) @@ -59,30 +61,34 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat return router; } -async function findOrCreateAuthByAuthIdentity( +async function getOrCreateUserIdByOAuthProvider( providerName: string, providerUserId: string, getUserFields: () => ReturnType, ) { - // Attempt to find a User by an external auth association. - const authIdentity = await prisma.{= authIdentityEntityLower =}.findFirst({ - where: { providerName, providerUserId }, - include: { - {= authFieldOnAuthIdentityEntityName =}: { - include: { - {= userFieldOnAuthEntityName =}: true + try { + const authIdentity = await prisma.{= authIdentityEntityLower =}.findFirst({ + where: { providerName, providerUserId }, + include: { + {= authFieldOnAuthIdentityEntityName =}: { + include: { + {= userFieldOnAuthEntityName =}: true + } } } + }) + + + if (authIdentity) { + return authIdentity.{= authFieldOnAuthIdentityEntityName =}.{= userFieldOnAuthEntityName =}.id } - }) + + const userFields = await getUserFields() + + const auth = await createAuthWithUser(providerName, providerUserId, undefined, userFields) - if (authIdentity) { - return authIdentity.{= authFieldOnAuthIdentityEntityName =} + return auth.userId; + } catch (e) { + rethrowPossibleAuthError(e) } - - const userFields = await getUserFields() - - const auth = await createAuthWithUser(providerName, providerUserId, undefined, userFields) - // NOTE: we are fetching the auth again becuase it incldues nested user - return findAuthWithUserBy({ id: auth.id }); } diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index aa8cd0e80e..26c7f6147e 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -21,6 +21,28 @@ const _waspAdditionalSignupFieldsConfig = {= additionalSignupFields.importIdenti const _waspAdditionalSignupFieldsConfig = {} as ReturnType {=/ additionalSignupFields.isDefined =} +export type EmailProviderData = { + password: string; + isEmailVerified: boolean; + emailVerificationSentAt: Date | null; + passwordResetSentAt: Date | null; +} + +export type UsernameProviderData = { + password: string; +} + +export type OAuthProviderData = {} + +export type PossibleProviderData = { + email: EmailProviderData; + username: UsernameProviderData; + google: OAuthProviderData; + github: OAuthProviderData; +} + +type ProviderName = keyof PossibleProviderData + export const contextWithUserEntity = { entities: { {= userEntityUpper =}: prisma.{= userEntityLower =} @@ -32,7 +54,7 @@ export const authConfig = { successRedirectPath: "{= successRedirectPath =}", } -export async function findAuthIdentity(providerName: string, providerUserId: string) { +export async function findAuthIdentity(providerName: ProviderName, providerUserId: string) { return prisma.{= authIdentityEntityLower =}.findUnique({ where: { providerName_providerUserId: { @@ -43,14 +65,11 @@ export async function findAuthIdentity(providerName: string, providerUserId: str }); } -export async function findAuthIdentityByAuthId(authId: string) { - return prisma.{= authIdentityEntityLower =}.findFirst({ where: { authId } }); -} - -export async function updateAuthIdentityProviderData( - authId: string, - existingProviderData: ProviderData[ProviderName], - providerDataUpdates: Partial, +export async function updateAuthIdentityProviderData( + providerName: string, + providerUserId: string, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, ) { // We are doing the sanitization here only on updates to avoid // hashing the password multiple times. @@ -59,9 +78,14 @@ export async function updateAuthIdentityProviderData(newProviderData); - return prisma.{= authIdentityEntityLower =}.updateMany({ - where: { authId }, + const serializedProviderData = await serializeProviderData(newProviderData); + return prisma.{= authIdentityEntityLower =}.update({ + where: { + providerName_providerUserId: { + providerName, + providerUserId: providerUserId.toLowerCase(), + } + }, data: { providerData: serializedProviderData }, }); } @@ -96,7 +120,7 @@ export async function createAuthWithUser( } }) } catch (e) { - rethrowPossiblePrismaError(e); + rethrowPossibleAuthError(e); } } @@ -106,14 +130,14 @@ export async function deleteUserByAuthId(authId: string) { id: authId, } } }) } catch (e) { - rethrowPossiblePrismaError(e); + rethrowPossibleAuthError(e); } } export async function createAuthToken( - auth: {= authEntityUpper =} & { {= userFieldOnAuthEntityName =}: {= userEntityUpper =} } + userId: {= userEntityUpper =}['id'] ): Promise { - return sign(auth.{= userFieldOnAuthEntityName =}.id); + return sign(userId); } export async function verifyToken(token: string): Promise<{ id: any }> { @@ -132,41 +156,43 @@ export async function doFakeWork() { return sleep(timeToWork); } -export function rethrowPossiblePrismaError(e: unknown): void { +export function rethrowPossibleAuthError(e: unknown): void { if (e instanceof AuthError) { throwValidationError(e.message); - } else if (isPrismaError(e)) { - throw prismaErrorToHttpError(e) - } else { - throw new HttpError(500) } -} - -const isPrismaError = (e: unknown) => { - return ( - e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError - ) -} - -const prismaErrorToHttpError = (e: unknown) => { + + // Prisma code P2002 is for unique constraint violations. if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return new HttpError(422, 'Save failed', { + 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. + // required fields missing in the request, we want the developer + // to know about it. console.error(e) - return new HttpError(422, 'Save failed', { + throw new HttpError(422, 'Save failed', { message: 'there was a database error' }) } - return new HttpError(500) + + // 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`, + }) + } + + // NOTE: Giving extra info to the developer since we don't send + // the error message to the client. + console.error(e) + throw new HttpError(500) } export async function validateAndGetAdditionalFields(data: { @@ -188,31 +214,12 @@ export async function validateAndGetAdditionalFields(data: { return result; } -export type EmailProviderData = { - password: string; - isEmailVerified: boolean; - emailVerificationSentAt: Date | null; - passwordResetSentAt: Date | null; -} - -export type UsernameProviderData = { - password: string; -} - -export type OAuthProviderData = {} - -export type ProviderData = { - email: EmailProviderData; - username: UsernameProviderData; - oauth: OAuthProviderData; -} - -export function deserializeProviderData( +export function deserializeAndSanitizeProviderData( providerData: string, { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {}, -): ProviderData[ProviderName] { +): PossibleProviderData[PN] { // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON. - let data = JSON.parse(providerData) as ProviderData[ProviderName]; + let data = JSON.parse(providerData) as PossibleProviderData[PN]; if (providerDataHasPasswordField(data) && shouldRemovePasswordField) { delete data[PASSWORD_FIELD]; @@ -221,17 +228,17 @@ export function deserializeProviderData return data; } -export async function sanitizeAndSerializeProviderData(providerData: ProviderData[ProviderName]) { +export async function sanitizeAndSerializeProviderData(providerData: PossibleProviderData[PN]) { return serializeProviderData( await sanitizeProviderData(providerData) ); } -async function serializeProviderData(providerData: ProviderData[ProviderName]) { +async function serializeProviderData(providerData: PossibleProviderData[PN]) { return JSON.stringify(providerData); } -async function sanitizeProviderData(providerData: ProviderData[ProviderName]) { +async function sanitizeProviderData(providerData: PossibleProviderData[PN]) { // NOTE: doing a shallow copy here as we expect the providerData to be // a flat object. If it's not, we'll have to do a deep copy. const data = { @@ -245,6 +252,6 @@ async function sanitizeProviderData(pro } -function providerDataHasPasswordField(providerData: ProviderData[keyof ProviderData]): providerData is { password: string } { +function providerDataHasPasswordField(providerData: PossibleProviderData[keyof PossibleProviderData]): providerData is { password: string } { return PASSWORD_FIELD in providerData; } diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js index 8863e8a12d..2e2f84c39c 100644 --- a/waspc/data/Generator/templates/server/src/core/auth.js +++ b/waspc/data/Generator/templates/server/src/core/auth.js @@ -8,7 +8,7 @@ import prisma from '../dbClient.js' import { handleRejection } from '../utils.js' import HttpError from '../core/HttpError.js' import config from '../config.js' -import { deserializeProviderData } from '../auth/utils.js' +import { deserializeAndSanitizeProviderData } from '../auth/utils.js' const jwtSign = util.promisify(jwt.sign) const jwtVerify = util.promisify(jwt.verify) @@ -70,7 +70,7 @@ export async function getUserFromToken(token) { // https://github.com/wasp-lang/wasp/issues/965 let sanitizedUser = { ...user } sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =} = sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =}.map(identity => { - identity.providerData = deserializeProviderData(identity.providerData, { shouldRemovePasswordField: true }) + identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true }) return identity }); return sanitizedUser From f982e6dd224adfdce816c692fa36695c361bc451 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 18 Dec 2023 14:52:21 +0100 Subject: [PATCH 29/62] Updates e2e tests --- .../waspBuild/.wasp/build/.waspchecksums | 2 +- .../waspBuild/.wasp/build/server/src/utils.ts | 40 +----- .../waspCompile/.wasp/out/.waspchecksums | 2 +- .../waspCompile/.wasp/out/server/src/utils.ts | 40 +----- .../waspComplexTest/.wasp/out/.waspchecksums | 8 +- .../src/auth/providers/oauth/createRouter.ts | 46 +++--- .../.wasp/out/server/src/auth/utils.ts | 132 +++++++++++------- .../.wasp/out/server/src/core/auth.js | 4 +- .../.wasp/out/server/src/utils.ts | 40 +----- .../waspJob/.wasp/out/.waspchecksums | 2 +- .../waspJob/.wasp/out/server/src/utils.ts | 40 +----- .../waspMigrate/.wasp/out/.waspchecksums | 2 +- .../waspMigrate/.wasp/out/server/src/utils.ts | 40 +----- 13 files changed, 150 insertions(+), 248 deletions(-) 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 451835e286..d1cb8ba9fc 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 @@ -221,7 +221,7 @@ "file", "server/src/utils.ts" ], - "71309895e3d07e160f542ec42ae6d4ae4adce44824e1ebc96e922bb8e76dd125" + "e49fdb3ce411e98deafd23590e81404fe8234d28bf32c5fcc829274fc84f8ba2" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.ts index 10ee679953..8f44b9be36 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.ts +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.ts @@ -1,12 +1,10 @@ import { Request, Response, NextFunction } from 'express' -import { Prisma } from '@prisma/client' +import Prisma from '@prisma/client' import { readdir } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import HttpError from './core/HttpError.js' -import { type SanitizedUser } from './_types/index.js' /** * Decorator for async express middleware that handles promise rejections. @@ -15,15 +13,16 @@ import { type SanitizedUser } from './_types/index.js' * if given middleware returns promise, reject of that promise will be correctly handled, * meaning that error will be forwarded to next(). */ -type RequestWithUser = Request & { user?: SanitizedUser } +type RequestWithExtraFields = Request & { +} export const handleRejection = ( middleware: ( - req: RequestWithUser, + req: RequestWithExtraFields, res: Response, next: NextFunction - ) => Promise + ) => any ) => -async (req: RequestWithUser, res: Response, next: NextFunction) => { +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { try { await middleware(req, res, next) } catch (error) { @@ -31,33 +30,6 @@ async (req: RequestWithUser, res: Response, next: NextFunction) => { } } -export const isPrismaError = (e: unknown) => { - return ( - e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError - ) -} - -export const prismaErrorToHttpError = (e: unknown) => { - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return 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. - console.error(e) - return new HttpError(422, 'Save failed', { - message: 'there was a database error' - }) - } - return new HttpError(500) -} - export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) export function getDirFromFileUrl(fileUrl: string) { 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 b401780f04..99cab8bf82 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 @@ -228,7 +228,7 @@ "file", "server/src/utils.ts" ], - "71309895e3d07e160f542ec42ae6d4ae4adce44824e1ebc96e922bb8e76dd125" + "e49fdb3ce411e98deafd23590e81404fe8234d28bf32c5fcc829274fc84f8ba2" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.ts index 10ee679953..8f44b9be36 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.ts +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.ts @@ -1,12 +1,10 @@ import { Request, Response, NextFunction } from 'express' -import { Prisma } from '@prisma/client' +import Prisma from '@prisma/client' import { readdir } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import HttpError from './core/HttpError.js' -import { type SanitizedUser } from './_types/index.js' /** * Decorator for async express middleware that handles promise rejections. @@ -15,15 +13,16 @@ import { type SanitizedUser } from './_types/index.js' * if given middleware returns promise, reject of that promise will be correctly handled, * meaning that error will be forwarded to next(). */ -type RequestWithUser = Request & { user?: SanitizedUser } +type RequestWithExtraFields = Request & { +} export const handleRejection = ( middleware: ( - req: RequestWithUser, + req: RequestWithExtraFields, res: Response, next: NextFunction - ) => Promise + ) => any ) => -async (req: RequestWithUser, res: Response, next: NextFunction) => { +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { try { await middleware(req, res, next) } catch (error) { @@ -31,33 +30,6 @@ async (req: RequestWithUser, res: Response, next: NextFunction) => { } } -export const isPrismaError = (e: unknown) => { - return ( - e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError - ) -} - -export const prismaErrorToHttpError = (e: unknown) => { - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return 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. - console.error(e) - return new HttpError(422, 'Save failed', { - message: 'there was a database error' - }) - } - return new HttpError(500) -} - export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) export function getDirFromFileUrl(fileUrl: string) { 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 8c152819f5..c8007ceba3 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 @@ -151,7 +151,7 @@ "file", "server/src/auth/providers/oauth/createRouter.ts" ], - "43a3fe6d219c41cb5abc654a0f0ac50cc96276810cbbdab065282afb788e3a38" + "7d95e813c027051ed196e258234a87490cd67ea732a1a917c076f459805aaf95" ], [ [ @@ -179,7 +179,7 @@ "file", "server/src/auth/utils.ts" ], - "36314717a376d862c6fc5422e2a835bc810a15b87f624206d815a8c8a34651cf" + "3939168a308688ee2299b01c1fd62714deda92e372d0eb62967e9b164d6bde5d" ], [ [ @@ -214,7 +214,7 @@ "file", "server/src/core/auth.js" ], - "d47b82625a629ce6c1517412ce60cc2e22f38e1dae64d6fd155a537ecb5b0bbe" + "1cd90980d148647b1fa966d7ceb5bd44a258c68b66afc83c2b7ffd54a986ca44" ], [ [ @@ -508,7 +508,7 @@ "file", "server/src/utils.ts" ], - "71309895e3d07e160f542ec42ae6d4ae4adce44824e1ebc96e922bb8e76dd125" + "cd9bb8d9368b52b45c8250f1227bb91f29549fa2363e3d4440377d393c159857" ], [ [ 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 c0477c47d0..ba72fa8df4 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 @@ -4,12 +4,13 @@ import passport from "passport" import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js' -import { sign } from '../../../core/auth.js' import { authConfig, contextWithUserEntity, createAuthWithUser, findAuthWithUserBy, + createAuthToken, + rethrowPossibleAuthError, } from "../../utils.js" import type { ProviderConfig, RequestWithWasp } from "../types.js" @@ -48,9 +49,10 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat const getUserFields = () => getUserFieldsFn ? getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) : Promise.resolve({}); // TODO: In the future we could make this configurable, possibly associating an external account // with the currently logged in account, or by some DB lookup. - const auth = await findOrCreateAuthByAuthIdentity(provider.id, providerProfile.id, getUserFields); + const userId = await getOrCreateUserIdByOAuthProvider(provider.id, providerProfile.id, getUserFields); + + const token = await createAuthToken(userId); - const token = await sign(auth.user.id); res.json({ token }); }) ) @@ -58,30 +60,34 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat return router; } -async function findOrCreateAuthByAuthIdentity( +async function getOrCreateUserIdByOAuthProvider( providerName: string, providerUserId: string, getUserFields: () => ReturnType, ) { - // Attempt to find a User by an external auth association. - const authIdentity = await prisma.authIdentity.findFirst({ - where: { providerName, providerUserId }, - include: { - auth: { - include: { - user: true + try { + const authIdentity = await prisma.authIdentity.findFirst({ + where: { providerName, providerUserId }, + include: { + auth: { + include: { + user: true + } } } + }) + + + if (authIdentity) { + return authIdentity.auth.user.id } - }) + + const userFields = await getUserFields() + + const auth = await createAuthWithUser(providerName, providerUserId, undefined, userFields) - if (authIdentity) { - return authIdentity.auth + return auth.userId; + } catch (e) { + rethrowPossibleAuthError(e) } - - const userFields = await getUserFields() - - const auth = await createAuthWithUser(providerName, providerUserId, undefined, userFields) - // NOTE: we are fetching the auth again becuase it incldues nested user - return findAuthWithUserBy({ id: auth.id }); } 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 d4efc8596d..f611578c7c 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 @@ -2,9 +2,9 @@ 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 { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js' +import { sleep } from '../utils.js' import { type User, type Auth } from '../entities/index.js' -import { type Prisma } from '@prisma/client'; +import { Prisma } from '@prisma/client'; import { PASSWORD_FIELD, throwValidationError } from './validation.js' @@ -12,6 +12,28 @@ import { PASSWORD_FIELD, throwValidationError } from './validation.js' import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js' const _waspAdditionalSignupFieldsConfig = {} as ReturnType +export type EmailProviderData = { + password: string; + isEmailVerified: boolean; + emailVerificationSentAt: Date | null; + passwordResetSentAt: Date | null; +} + +export type UsernameProviderData = { + password: string; +} + +export type OAuthProviderData = {} + +export type PossibleProviderData = { + email: EmailProviderData; + username: UsernameProviderData; + google: OAuthProviderData; + github: OAuthProviderData; +} + +type ProviderName = keyof PossibleProviderData + export const contextWithUserEntity = { entities: { User: prisma.user @@ -23,7 +45,7 @@ export const authConfig = { successRedirectPath: "/", } -export async function findAuthIdentity(providerName: string, providerUserId: string) { +export async function findAuthIdentity(providerName: ProviderName, providerUserId: string) { return prisma.authIdentity.findUnique({ where: { providerName_providerUserId: { @@ -34,14 +56,11 @@ export async function findAuthIdentity(providerName: string, providerUserId: str }); } -export async function findAuthIdentityByAuthId(authId: string) { - return prisma.authIdentity.findFirst({ where: { authId } }); -} - -export async function updateAuthIdentityProviderData( - authId: string, - existingProviderData: ProviderData[ProviderName], - providerDataUpdates: Partial, +export async function updateAuthIdentityProviderData( + providerName: string, + providerUserId: string, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, ) { // We are doing the sanitization here only on updates to avoid // hashing the password multiple times. @@ -50,9 +69,14 @@ export async function updateAuthIdentityProviderData(newProviderData); - return prisma.authIdentity.updateMany({ - where: { authId }, + const serializedProviderData = await serializeProviderData(newProviderData); + return prisma.authIdentity.update({ + where: { + providerName_providerUserId: { + providerName, + providerUserId: providerUserId.toLowerCase(), + } + }, data: { providerData: serializedProviderData }, }); } @@ -87,7 +111,7 @@ export async function createAuthWithUser( } }) } catch (e) { - rethrowPossiblePrismaError(e); + rethrowPossibleAuthError(e); } } @@ -97,14 +121,14 @@ export async function deleteUserByAuthId(authId: string) { id: authId, } } }) } catch (e) { - rethrowPossiblePrismaError(e); + rethrowPossibleAuthError(e); } } export async function createAuthToken( - auth: Auth & { user: User } + userId: User['id'] ): Promise { - return sign(auth.user.id); + return sign(userId); } export async function verifyToken(token: string): Promise<{ id: any }> { @@ -123,14 +147,43 @@ export async function doFakeWork() { return sleep(timeToWork); } -export function rethrowPossiblePrismaError(e: unknown): void { +export function rethrowPossibleAuthError(e: unknown): void { if (e instanceof AuthError) { throwValidationError(e.message); - } else if (isPrismaError(e)) { - throw prismaErrorToHttpError(e) - } else { - throw new HttpError(500) } + + // 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`, + }) + } + + // NOTE: Giving extra info to the developer since we don't send + // the error message to the client. + console.error(e) + throw new HttpError(500) } export async function validateAndGetAdditionalFields(data: { @@ -152,31 +205,12 @@ export async function validateAndGetAdditionalFields(data: { return result; } -export type EmailProviderData = { - password: string; - isEmailVerified: boolean; - emailVerificationSentAt: Date | null; - passwordResetSentAt: Date | null; -} - -export type UsernameProviderData = { - password: string; -} - -export type OAuthProviderData = {} - -export type ProviderData = { - email: EmailProviderData; - username: UsernameProviderData; - oauth: OAuthProviderData; -} - -export function deserializeProviderData( +export function deserializeAndSanitizeProviderData( providerData: string, { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {}, -): ProviderData[ProviderName] { +): PossibleProviderData[PN] { // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON. - let data = JSON.parse(providerData) as ProviderData[ProviderName]; + let data = JSON.parse(providerData) as PossibleProviderData[PN]; if (providerDataHasPasswordField(data) && shouldRemovePasswordField) { delete data[PASSWORD_FIELD]; @@ -185,17 +219,17 @@ export function deserializeProviderData return data; } -export async function sanitizeAndSerializeProviderData(providerData: ProviderData[ProviderName]) { +export async function sanitizeAndSerializeProviderData(providerData: PossibleProviderData[PN]) { return serializeProviderData( await sanitizeProviderData(providerData) ); } -async function serializeProviderData(providerData: ProviderData[ProviderName]) { +async function serializeProviderData(providerData: PossibleProviderData[PN]) { return JSON.stringify(providerData); } -async function sanitizeProviderData(providerData: ProviderData[ProviderName]) { +async function sanitizeProviderData(providerData: PossibleProviderData[PN]) { // NOTE: doing a shallow copy here as we expect the providerData to be // a flat object. If it's not, we'll have to do a deep copy. const data = { @@ -209,6 +243,6 @@ async function sanitizeProviderData(pro } -function providerDataHasPasswordField(providerData: ProviderData[keyof ProviderData]): providerData is { password: string } { +function providerDataHasPasswordField(providerData: PossibleProviderData[keyof PossibleProviderData]): providerData is { password: string } { return PASSWORD_FIELD in providerData; } 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 cb3cf853d9..11be85976a 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 @@ -7,7 +7,7 @@ import prisma from '../dbClient.js' import { handleRejection } from '../utils.js' import HttpError from '../core/HttpError.js' import config from '../config.js' -import { deserializeProviderData } from '../auth/utils.js' +import { deserializeAndSanitizeProviderData } from '../auth/utils.js' const jwtSign = util.promisify(jwt.sign) const jwtVerify = util.promisify(jwt.verify) @@ -69,7 +69,7 @@ export async function getUserFromToken(token) { // https://github.com/wasp-lang/wasp/issues/965 let sanitizedUser = { ...user } sanitizedUser.auth.identities = sanitizedUser.auth.identities.map(identity => { - identity.providerData = deserializeProviderData(identity.providerData, { shouldRemovePasswordField: true }) + identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true }) return identity }); return sanitizedUser 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 10ee679953..ca6e626481 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 @@ -1,11 +1,10 @@ import { Request, Response, NextFunction } from 'express' -import { Prisma } from '@prisma/client' +import Prisma from '@prisma/client' import { readdir } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import HttpError from './core/HttpError.js' import { type SanitizedUser } from './_types/index.js' /** @@ -15,15 +14,17 @@ import { type SanitizedUser } from './_types/index.js' * if given middleware returns promise, reject of that promise will be correctly handled, * meaning that error will be forwarded to next(). */ -type RequestWithUser = Request & { user?: SanitizedUser } +type RequestWithExtraFields = Request & { + user?: SanitizedUser +} export const handleRejection = ( middleware: ( - req: RequestWithUser, + req: RequestWithExtraFields, res: Response, next: NextFunction - ) => Promise + ) => any ) => -async (req: RequestWithUser, res: Response, next: NextFunction) => { +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { try { await middleware(req, res, next) } catch (error) { @@ -31,33 +32,6 @@ async (req: RequestWithUser, res: Response, next: NextFunction) => { } } -export const isPrismaError = (e: unknown) => { - return ( - e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError - ) -} - -export const prismaErrorToHttpError = (e: unknown) => { - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return 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. - console.error(e) - return new HttpError(422, 'Save failed', { - message: 'there was a database error' - }) - } - return new HttpError(500) -} - export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) export function getDirFromFileUrl(fileUrl: string) { 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 435e40d9ef..65b72c5c4d 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 @@ -270,7 +270,7 @@ "file", "server/src/utils.ts" ], - "71309895e3d07e160f542ec42ae6d4ae4adce44824e1ebc96e922bb8e76dd125" + "e49fdb3ce411e98deafd23590e81404fe8234d28bf32c5fcc829274fc84f8ba2" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.ts index 10ee679953..8f44b9be36 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.ts @@ -1,12 +1,10 @@ import { Request, Response, NextFunction } from 'express' -import { Prisma } from '@prisma/client' +import Prisma from '@prisma/client' import { readdir } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import HttpError from './core/HttpError.js' -import { type SanitizedUser } from './_types/index.js' /** * Decorator for async express middleware that handles promise rejections. @@ -15,15 +13,16 @@ import { type SanitizedUser } from './_types/index.js' * if given middleware returns promise, reject of that promise will be correctly handled, * meaning that error will be forwarded to next(). */ -type RequestWithUser = Request & { user?: SanitizedUser } +type RequestWithExtraFields = Request & { +} export const handleRejection = ( middleware: ( - req: RequestWithUser, + req: RequestWithExtraFields, res: Response, next: NextFunction - ) => Promise + ) => any ) => -async (req: RequestWithUser, res: Response, next: NextFunction) => { +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { try { await middleware(req, res, next) } catch (error) { @@ -31,33 +30,6 @@ async (req: RequestWithUser, res: Response, next: NextFunction) => { } } -export const isPrismaError = (e: unknown) => { - return ( - e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError - ) -} - -export const prismaErrorToHttpError = (e: unknown) => { - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return 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. - console.error(e) - return new HttpError(422, 'Save failed', { - message: 'there was a database error' - }) - } - return new HttpError(500) -} - export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) export function getDirFromFileUrl(fileUrl: string) { 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 c6e5b3216c..008666643a 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 @@ -228,7 +228,7 @@ "file", "server/src/utils.ts" ], - "71309895e3d07e160f542ec42ae6d4ae4adce44824e1ebc96e922bb8e76dd125" + "e49fdb3ce411e98deafd23590e81404fe8234d28bf32c5fcc829274fc84f8ba2" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.ts index 10ee679953..8f44b9be36 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.ts @@ -1,12 +1,10 @@ import { Request, Response, NextFunction } from 'express' -import { Prisma } from '@prisma/client' +import Prisma from '@prisma/client' import { readdir } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import HttpError from './core/HttpError.js' -import { type SanitizedUser } from './_types/index.js' /** * Decorator for async express middleware that handles promise rejections. @@ -15,15 +13,16 @@ import { type SanitizedUser } from './_types/index.js' * if given middleware returns promise, reject of that promise will be correctly handled, * meaning that error will be forwarded to next(). */ -type RequestWithUser = Request & { user?: SanitizedUser } +type RequestWithExtraFields = Request & { +} export const handleRejection = ( middleware: ( - req: RequestWithUser, + req: RequestWithExtraFields, res: Response, next: NextFunction - ) => Promise + ) => any ) => -async (req: RequestWithUser, res: Response, next: NextFunction) => { +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { try { await middleware(req, res, next) } catch (error) { @@ -31,33 +30,6 @@ async (req: RequestWithUser, res: Response, next: NextFunction) => { } } -export const isPrismaError = (e: unknown) => { - return ( - e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError - ) -} - -export const prismaErrorToHttpError = (e: unknown) => { - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return 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. - console.error(e) - return new HttpError(422, 'Save failed', { - message: 'there was a database error' - }) - } - return new HttpError(500) -} - export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) export function getDirFromFileUrl(fileUrl: string) { From 48484c17b573d0df031a6b549e9ee03d39a9ac32 Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Mon, 18 Dec 2023 16:20:56 +0100 Subject: [PATCH 30/62] Add user ID helpers --- examples/thoughts/src/client/TopNavbar.jsx | 7 +--- .../todo-typescript/src/client/MainPage.tsx | 7 +--- examples/waspello/src/client/Navbar.jsx | 14 ++++++-- .../src/client/Layout.jsx | 11 +++---- .../src/server/ws-server.ts | 4 +-- .../templates/react-app/src/auth/user.ts | 15 +++++++++ .../templates/server/src/auth/user.ts | 15 +++++++++ .../crud-testing/src/client/MainPage.tsx | 12 ++----- waspc/examples/todoApp/src/client/App.tsx | 5 ++- .../todoApp/src/client/pages/ProfilePage.tsx | 19 +++++------ waspc/examples/todoApp/src/client/user.ts | 33 +++++++++++++++++++ .../examples/todoApp/src/client/App.tsx | 5 ++- .../todoApp/src/client/pages/ProfilePage.tsx | 25 +++++++------- .../examples/todoApp/src/client/user.ts | 33 +++++++++++++++++++ waspc/headless-test/start.js | 19 ++++++----- .../Wasp/Generator/ServerGenerator/AuthG.hs | 3 +- .../Wasp/Generator/WebAppGenerator/AuthG.hs | 1 + 17 files changed, 160 insertions(+), 68 deletions(-) create mode 100644 waspc/data/Generator/templates/react-app/src/auth/user.ts create mode 100644 waspc/data/Generator/templates/server/src/auth/user.ts create mode 100644 waspc/examples/todoApp/src/client/user.ts create mode 100644 waspc/headless-test/examples/todoApp/src/client/user.ts diff --git a/examples/thoughts/src/client/TopNavbar.jsx b/examples/thoughts/src/client/TopNavbar.jsx index 8177bf5f85..5e032a47a8 100644 --- a/examples/thoughts/src/client/TopNavbar.jsx +++ b/examples/thoughts/src/client/TopNavbar.jsx @@ -4,12 +4,7 @@ import logout from "@wasp/auth/logout"; import "./TopNavbar.css"; -function getUsername(user) { - const usernameIdentity = user.auth.identities.find( - (identity) => identity.providerName === "username" - ); - return usernameIdentity ? usernameIdentity.providerUserId : null; -} +import { getUsername } from "@wasp/auth/user"; const TopNavbar = ({ user }) => { const username = getUsername(user); diff --git a/examples/todo-typescript/src/client/MainPage.tsx b/examples/todo-typescript/src/client/MainPage.tsx index 6461071c71..44dd312539 100644 --- a/examples/todo-typescript/src/client/MainPage.tsx +++ b/examples/todo-typescript/src/client/MainPage.tsx @@ -11,12 +11,7 @@ import updateTask from "@wasp/actions/updateTask"; import deleteTasks from "@wasp/actions/deleteTasks"; import type { Task } from "@wasp/entities"; import type { User } from "@wasp/auth/types"; - -function getUsername(user: User) { - return user.auth?.identities.find( - (identity) => identity.providerName === "username" - )?.providerUserId; -} +import { getUsername } from "@wasp/auth/user"; export const MainPage = ({ user }: { user: User }) => { const { data: tasks, isLoading, error } = useQuery(getTasks); diff --git a/examples/waspello/src/client/Navbar.jsx b/examples/waspello/src/client/Navbar.jsx index 336807dee4..8fac1047ee 100644 --- a/examples/waspello/src/client/Navbar.jsx +++ b/examples/waspello/src/client/Navbar.jsx @@ -5,8 +5,18 @@ import logout from "@wasp/auth/logout"; import logo from "./waspello-logo-navbar.svg"; import "./Navbar.css"; +import { findUserIdentity, getUsername } from "@wasp/auth/user"; + const Navbar = ({ user }) => { - const identifier = user.auth?.identities[0]?.providerUserId; + // We have to ways of authenticating users, so + // we have to check which one is used. + const googleIdentity = findUserIdentity(user, "google"); + const usernameIdentity = findUserIdentity(user, "username"); + + const username = usernameIdentity + ? getUsername(user) + : `Google user ${googleIdentity.providerUserId}`; + return (
@@ -17,7 +27,7 @@ const Navbar = ({ user }) => {
- {identifier} + {username}  |  diff --git a/waspc/examples/todoApp/src/client/App.tsx b/waspc/examples/todoApp/src/client/App.tsx index 8356f3e776..acdbe144a7 100644 --- a/waspc/examples/todoApp/src/client/App.tsx +++ b/waspc/examples/todoApp/src/client/App.tsx @@ -7,6 +7,7 @@ import getDate from '@wasp/queries/getDate' import { useSocket } from '@wasp/webSocket' import './Main.css' +import { getName } from './user' export function App({ children }: any) { const { data: user } = useAuth() @@ -15,8 +16,6 @@ export function App({ children }: any) { const connectionIcon = isConnected ? '🟢' : '🔴' - const identity = user?.auth?.identities[0] - return (
@@ -29,7 +28,7 @@ export function App({ children }: any) { {user && (
- Hello, {identity?.providerUserId} + Hello, {getName(user)}