Adds new complex e2e test (#1030)

This commit is contained in:
Mihovil Ilakovac 2023-03-02 15:05:24 +01:00 committed by GitHub
parent 6dcfb290d9
commit bbaf8bdf3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
134 changed files with 4330 additions and 6 deletions

View File

@ -3,6 +3,7 @@ import System.Info (os)
import Test.Tasty (TestTree, defaultMain, testGroup)
import Tests.WaspBuildTest (waspBuild)
import Tests.WaspCompileTest (waspCompile)
import Tests.WaspComplexTest (waspComplexTest)
import Tests.WaspJobTest (waspJob)
import Tests.WaspMigrateTest (waspMigrate)
import Tests.WaspNewTest (waspNew)
@ -25,5 +26,6 @@ tests = do
waspCompile,
waspMigrate,
waspBuild,
waspJob
waspJob,
waspComplexTest
]

View File

@ -1,4 +1,5 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE InstanceSigs #-}
module ShellCommands
( ShellCommand,
@ -15,6 +16,7 @@ module ShellCommands
waspCliMigrate,
waspCliBuild,
dockerBuild,
insertCodeIntoWaspFileAtLineNumber,
)
where
@ -71,11 +73,14 @@ createFile content relDirFp filename = return $ combineShellCommands [createPare
-- NOTE: This is fragile and will likely break in future. Assumes `app` decl is first line and by default
-- we do not have a `db` field. Consider better alternatives.
setDbToPSQL :: ShellCommandBuilder ShellCommand
setDbToPSQL =
-- Change DB to postgres by adding string at specific line so it still parses.
setDbToPSQL = insertCodeIntoWaspFileAtLineNumber 2 " db: { system: PostgreSQL },"
insertCodeIntoWaspFileAtLineNumber :: Int -> String -> ShellCommandBuilder ShellCommand
insertCodeIntoWaspFileAtLineNumber atLineNumber line =
return $
combineShellCommands
[ -- Change DB to postgres by adding string at specific line so it still parses.
"awk 'NR==2{print \" db: { system: PostgreSQL },\"}1' main.wasp > main.wasp.tmp",
[ "awk 'NR==" ++ show atLineNumber ++ "{print " ++ show line ++ "}1' main.wasp > main.wasp.tmp",
"mv main.wasp.tmp main.wasp"
]

View File

@ -0,0 +1,230 @@
module Tests.WaspComplexTest (waspComplexTest) where
import GoldenTest (GoldenTest, makeGoldenTest)
import ShellCommands
( ShellCommand,
ShellCommandBuilder,
appendToWaspFile,
cdIntoCurrentProject,
createFile,
insertCodeIntoWaspFileAtLineNumber,
setDbToPSQL,
waspCliCompile,
waspCliNew,
)
import Util ((<++>))
waspComplexTest :: GoldenTest
waspComplexTest = do
makeGoldenTest "waspComplexTest" $
sequence
[ waspCliNew,
cdIntoCurrentProject
]
<++> addServerEnvFile
<++> addDependencies
<++> addClientSetup
<++> addServerSetup
<++> addGoogleAuth
<++> addJob
<++> addAction
<++> addQuery
<++> sequence
[ waspCliCompile
]
addClientSetup :: ShellCommandBuilder [ShellCommand]
addClientSetup = do
sequence
[ createFile rootComponentContent "./src/client" "App.jsx",
createFile clientSetupFnContent "./src/client" "myClientSetupCode.js",
insertCodeIntoWaspFileAfterVersion clientField
]
where
clientField =
unlines
[ " client: {",
" setupFn: import myClientSetupFunction from \"@client/myClientSetupCode.js\",",
" rootComponent: import App from \"@client/App.jsx\"",
" },"
]
rootComponentContent =
unlines
[ "export default function App({ children }) {",
" return (",
" <div className=\"app\">",
" <h1>Root component</h1>",
" {children}",
" </div>",
" );",
"}"
]
clientSetupFnContent =
unlines
[ "export default function myClientSetupFunction() {",
" // Do some client setup here.",
"}"
]
addServerSetup :: ShellCommandBuilder [ShellCommand]
addServerSetup = do
sequence
[ createFile serverSetupFnContent "./src/server" "myServerSetupCode.js",
insertCodeIntoWaspFileAfterVersion serverField
]
where
serverField =
unlines
[ " server: {",
" setupFn: import mySetupFunction from \"@server/myServerSetupCode.js\",",
" },"
]
serverSetupFnContent =
unlines
[ "export default function mySetupFunction() {",
" // Do some server setup here.",
"}"
]
addJob :: ShellCommandBuilder [ShellCommand]
addJob = do
sequence
[ setDbToPSQL,
appendToWaspFile jobDecl,
createFile jobFile "./src/server/jobs" "bar.js"
]
where
jobDecl =
unlines
[ "job MySpecialJob {",
" executor: PgBoss,",
" perform: {",
" fn: import { foo } from \"@server/jobs/bar.js\"",
" }",
"}"
]
jobFile =
unlines
[ "export const foo = async (args) => {",
" return 1",
"}"
]
addServerEnvFile :: ShellCommandBuilder [ShellCommand]
addServerEnvFile = do
sequence [createFile envFileContents "./" ".env.server"]
where
envFileContents =
unlines
[ "GOOGLE_CLIENT_ID=google_client_id",
"GOOGLE_CLIENT_SECRET=google_client_secret"
]
addGoogleAuth :: ShellCommandBuilder [ShellCommand]
addGoogleAuth = do
sequence
[ insertCodeIntoWaspFileAfterVersion authField,
appendToWaspFile userEntity,
appendToWaspFile socialLoginEntity
]
where
authField =
unlines
[ " auth: {",
" userEntity: User,",
" externalAuthEntity: SocialLogin,",
" methods: {",
" google: {}",
" },",
" onAuthFailedRedirectTo: \"/login\"",
" },"
]
userEntity =
unlines
[ "entity User {=psl",
" id Int @id @default(autoincrement())",
" username String @unique",
" password String",
" externalAuthAssociations SocialLogin[]",
"psl=}"
]
socialLoginEntity =
unlines
[ "entity SocialLogin {=psl",
" id Int @id @default(autoincrement())",
" provider String",
" providerId String",
" user User @relation(fields: [userId], references: [id], onDelete: Cascade)",
" userId Int",
" createdAt DateTime @default(now())",
" @@unique([provider, providerId, userId])",
"psl=}"
]
addAction :: ShellCommandBuilder [ShellCommand]
addAction = do
sequence
[ appendToWaspFile actionDecl,
createFile actionFile "./src/server/actions" "bar.js"
]
where
actionDecl =
unlines
[ "action MySpecialAction {",
" fn: import { foo } from \"@server/actions/bar.js\",",
" entities: [User],",
"}"
]
actionFile =
unlines
[ "export const foo = async (args) => {",
" return 1",
"}"
]
addQuery :: ShellCommandBuilder [ShellCommand]
addQuery = do
sequence
[ appendToWaspFile queryDecl,
createFile queryFile "./src/server/queries" "bar.js"
]
where
queryDecl =
unlines
[ "query MySpecialQuery {",
" fn: import { foo } from \"@server/queries/bar.js\",",
" entities: [User],",
"}"
]
queryFile =
unlines
[ "export const foo = async (args) => {",
" return 1",
"}"
]
addDependencies :: ShellCommandBuilder [ShellCommand]
addDependencies = do
sequence
[ insertCodeIntoWaspFileAfterVersion deps
]
where
deps =
unlines
[ " dependencies: [",
" (\"redux\", \"^4.0.5\"),",
" (\"react-redux\", \"^7.1.3\")",
" ],"
]
insertCodeIntoWaspFileAfterVersion :: String -> ShellCommandBuilder ShellCommand
insertCodeIntoWaspFileAfterVersion = insertCodeIntoWaspFileAtLineNumber lineNumberInWaspFileAfterVersion
where
lineNumberInWaspFileAfterVersion :: Int
lineNumberInWaspFileAfterVersion = 5

View File

@ -12,7 +12,7 @@ import ShellCommands
waspJob :: GoldenTest
waspJob = do
let entityDecl =
let jobDecl =
" job MySpecialJob { \n\
\ executor: PgBoss, \n\
\ perform: { \n\
@ -30,7 +30,7 @@ waspJob = do
[ waspCliNew,
cdIntoCurrentProject,
setDbToPSQL,
appendToWaspFile entityDecl,
appendToWaspFile jobDecl,
createFile jobFile "./src/server/jobs" "bar.js",
waspCliCompile
]

8
waspc/e2e-test/Util.hs Normal file
View File

@ -0,0 +1,8 @@
module Util where
import Control.Applicative (liftA2)
infixr 5 <++>
(<++>) :: Applicative f => f [a] -> f [a] -> f [a]
(<++>) = liftA2 (++)

View File

@ -0,0 +1,127 @@
waspComplexTest/.env.server
waspComplexTest/.wasp/out/.dockerignore
waspComplexTest/.wasp/out/.waspchecksums
waspComplexTest/.wasp/out/Dockerfile
waspComplexTest/.wasp/out/db/package.json
waspComplexTest/.wasp/out/db/schema.prisma
waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum
waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json
waspComplexTest/.wasp/out/server/.env
waspComplexTest/.wasp/out/server/.npmrc
waspComplexTest/.wasp/out/server/README.md
waspComplexTest/.wasp/out/server/nodemon.json
waspComplexTest/.wasp/out/server/package.json
waspComplexTest/.wasp/out/server/patches/oauth+0.9.15.patch
waspComplexTest/.wasp/out/server/scripts/universal/validators.mjs
waspComplexTest/.wasp/out/server/scripts/validate-env.mjs
waspComplexTest/.wasp/out/server/src/_types/index.ts
waspComplexTest/.wasp/out/server/src/_types/taggedEntities.ts
waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.js
waspComplexTest/.wasp/out/server/src/actions/types.ts
waspComplexTest/.wasp/out/server/src/app.js
waspComplexTest/.wasp/out/server/src/config.js
waspComplexTest/.wasp/out/server/src/core/AuthError.js
waspComplexTest/.wasp/out/server/src/core/HttpError.js
waspComplexTest/.wasp/out/server/src/core/auth.js
waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js
waspComplexTest/.wasp/out/server/src/dbClient.js
waspComplexTest/.wasp/out/server/src/entities/index.ts
waspComplexTest/.wasp/out/server/src/ext-src/actions/bar.js
waspComplexTest/.wasp/out/server/src/ext-src/jobs/bar.js
waspComplexTest/.wasp/out/server/src/ext-src/myServerSetupCode.js
waspComplexTest/.wasp/out/server/src/ext-src/queries/bar.js
waspComplexTest/.wasp/out/server/src/jobs/MySpecialJob.js
waspComplexTest/.wasp/out/server/src/jobs/core/Job.js
waspComplexTest/.wasp/out/server/src/jobs/core/SubmittedJob.js
waspComplexTest/.wasp/out/server/src/jobs/core/allJobs.js
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.js
waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
waspComplexTest/.wasp/out/server/src/jobs/core/simpleJob.js
waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.js
waspComplexTest/.wasp/out/server/src/queries/types.ts
waspComplexTest/.wasp/out/server/src/routes/auth/index.js
waspComplexTest/.wasp/out/server/src/routes/auth/login.js
waspComplexTest/.wasp/out/server/src/routes/auth/me.js
waspComplexTest/.wasp/out/server/src/routes/auth/passport/generic/provider.js
waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/config.js
waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/configMapping.js
waspComplexTest/.wasp/out/server/src/routes/auth/passport/google/defaults.js
waspComplexTest/.wasp/out/server/src/routes/auth/passport/passport.js
waspComplexTest/.wasp/out/server/src/routes/auth/signup.js
waspComplexTest/.wasp/out/server/src/routes/auth/utils.js
waspComplexTest/.wasp/out/server/src/routes/index.js
waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialAction.js
waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialQuery.js
waspComplexTest/.wasp/out/server/src/routes/operations/index.js
waspComplexTest/.wasp/out/server/src/server.ts
waspComplexTest/.wasp/out/server/src/types/index.ts
waspComplexTest/.wasp/out/server/src/universal/url.ts
waspComplexTest/.wasp/out/server/src/utils.js
waspComplexTest/.wasp/out/server/tsconfig.json
waspComplexTest/.wasp/out/web-app/.npmrc
waspComplexTest/.wasp/out/web-app/README.md
waspComplexTest/.wasp/out/web-app/index.html
waspComplexTest/.wasp/out/web-app/netlify.toml
waspComplexTest/.wasp/out/web-app/package.json
waspComplexTest/.wasp/out/web-app/public/favicon.ico
waspComplexTest/.wasp/out/web-app/public/images/google-logo-icon.png
waspComplexTest/.wasp/out/web-app/public/manifest.json
waspComplexTest/.wasp/out/web-app/scripts/universal/validators.mjs
waspComplexTest/.wasp/out/web-app/scripts/validate-env.mjs
waspComplexTest/.wasp/out/web-app/src/actions/MySpecialAction.js
waspComplexTest/.wasp/out/web-app/src/actions/core.d.ts
waspComplexTest/.wasp/out/web-app/src/actions/core.js
waspComplexTest/.wasp/out/web-app/src/actions/index.ts
waspComplexTest/.wasp/out/web-app/src/api.js
waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.jsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.jsx
waspComplexTest/.wasp/out/web-app/src/auth/helpers/Google.jsx
waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts
waspComplexTest/.wasp/out/web-app/src/auth/login.js
waspComplexTest/.wasp/out/web-app/src/auth/logout.js
waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx
waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx
waspComplexTest/.wasp/out/web-app/src/auth/signup.js
waspComplexTest/.wasp/out/web-app/src/auth/useAuth.js
waspComplexTest/.wasp/out/web-app/src/config.js
waspComplexTest/.wasp/out/web-app/src/entities/index.ts
waspComplexTest/.wasp/out/web-app/src/ext-src/App.jsx
waspComplexTest/.wasp/out/web-app/src/ext-src/Main.css
waspComplexTest/.wasp/out/web-app/src/ext-src/MainPage.jsx
waspComplexTest/.wasp/out/web-app/src/ext-src/myClientSetupCode.js
waspComplexTest/.wasp/out/web-app/src/ext-src/vite-env.d.ts
waspComplexTest/.wasp/out/web-app/src/ext-src/waspLogo.png
waspComplexTest/.wasp/out/web-app/src/index.tsx
waspComplexTest/.wasp/out/web-app/src/logo.png
waspComplexTest/.wasp/out/web-app/src/operations/index.js
waspComplexTest/.wasp/out/web-app/src/operations/resources.js
waspComplexTest/.wasp/out/web-app/src/operations/updateHandlersMap.js
waspComplexTest/.wasp/out/web-app/src/queries/MySpecialQuery.js
waspComplexTest/.wasp/out/web-app/src/queries/core.d.ts
waspComplexTest/.wasp/out/web-app/src/queries/core.js
waspComplexTest/.wasp/out/web-app/src/queries/index.d.ts
waspComplexTest/.wasp/out/web-app/src/queries/index.js
waspComplexTest/.wasp/out/web-app/src/queryClient.js
waspComplexTest/.wasp/out/web-app/src/router.jsx
waspComplexTest/.wasp/out/web-app/src/universal/url.ts
waspComplexTest/.wasp/out/web-app/src/utils.js
waspComplexTest/.wasp/out/web-app/src/vite-env.d.ts
waspComplexTest/.wasp/out/web-app/tsconfig.json
waspComplexTest/.wasp/out/web-app/tsconfig.node.json
waspComplexTest/.wasp/out/web-app/vite.config.ts
waspComplexTest/.wasproot
waspComplexTest/main.wasp
waspComplexTest/src/.waspignore
waspComplexTest/src/client/App.jsx
waspComplexTest/src/client/Main.css
waspComplexTest/src/client/MainPage.jsx
waspComplexTest/src/client/myClientSetupCode.js
waspComplexTest/src/client/tsconfig.json
waspComplexTest/src/client/vite-env.d.ts
waspComplexTest/src/client/waspLogo.png
waspComplexTest/src/server/actions/bar.js
waspComplexTest/src/server/jobs/bar.js
waspComplexTest/src/server/myServerSetupCode.js
waspComplexTest/src/server/queries/bar.js
waspComplexTest/src/server/tsconfig.json
waspComplexTest/src/shared/tsconfig.json

View File

@ -0,0 +1,3 @@
GOOGLE_CLIENT_ID=google_client_id
GOOGLE_CLIENT_SECRET=google_client_secret

View File

@ -0,0 +1,2 @@
**/node_modules/
**/.git

View File

@ -0,0 +1,758 @@
[
[
[
"file",
".dockerignore"
],
"a2a5ebe3785244437c0d490b4bfa1652c84b9ab0d4f25566fd7e69d1421d7a1b"
],
[
[
"file",
"Dockerfile"
],
"276762c9c450a937b3fe64f660877e60b82ee12d5ef41cef894037dc0784ade5"
],
[
[
"file",
"db/schema.prisma"
],
"ca888a6ed14f8ad3980e4aa1c35d88a7268d8936134f78efc5a19add7abe9fc0"
],
[
[
"file",
"server/.env"
],
"7868f4dd9dc579bb21167f4e4cd5caa2960bf44ebe7d3f9f27a2f9380491d31f"
],
[
[
"file",
"server/.gitignore"
],
"2c3d81dd719ff91d51609a02d03ba62ad456e2c50763b7b4c761395cb583f890"
],
[
[
"file",
"server/.npmrc"
],
"8a74b1091affea3c71c2cf04acbaa38c4a51a261a9a98dcc9e96473d07a85a27"
],
[
[
"file",
"server/README.md"
],
"e14514b8b152dd2160a217d8fc66a710c8607c4ed1441a2148a0770701818249"
],
[
[
"file",
"server/nodemon.json"
],
"c4c69994805c8e0d993bd39fec9e52478b5e656bae565fa1c19ef378901a7139"
],
[
[
"file",
"server/package.json"
],
"45dd065b8f3937ad56587759a30dc25fc467783a8f9cdd6e68f15fbed5babdff"
],
[
[
"file",
"server/patches/oauth+0.9.15.patch"
],
"8b19a9638456f4f111d1fe95c90e9b0843773abaae62a356e3530609072560d0"
],
[
[
"file",
"server/scripts/universal/validators.mjs"
],
"94edf3906c43fb476b22244d07a5d69fd6c967de767532ed5b3215e2a6eee91b"
],
[
[
"file",
"server/scripts/validate-env.mjs"
],
"b507ff0f8cbdac18a0b42ccbb7acda9a5cd88e0ac5f95ca3d42341944298062d"
],
[
[
"file",
"server/src/_types/index.ts"
],
"64552c6b9e03c30d670161bd3912cd8050b9be8255f76a024c0995b9a38b5f55"
],
[
[
"file",
"server/src/_types/taggedEntities.ts"
],
"ac5570e21d89fbb8418a2fc26cbbec7b189c13b4bfab5f0b006d356411b8f5a2"
],
[
[
"file",
"server/src/actions/MySpecialAction.js"
],
"2dce5f6e711a9b7d94ce57c17ec27c5f86149baaf4a2ad9bc4a108756b13145e"
],
[
[
"file",
"server/src/actions/types.ts"
],
"225c35a21bce64ec2081013c7aadef5bb7ebbc3bdad617d08390164e150555aa"
],
[
[
"file",
"server/src/app.js"
],
"1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72"
],
[
[
"file",
"server/src/config.js"
],
"99e89fb4d207108caf0afeaf8f364819bac6f6d7c28a0a14b6ae7a4f134aa779"
],
[
[
"file",
"server/src/core/AuthError.js"
],
"5ac18ed20c5c8b192a75d1cb2f36906d5ff9f0951451a2ef08d9ab62fc26b8bb"
],
[
[
"file",
"server/src/core/HttpError.js"
],
"fa06ff60ab8183e8d00c13805692fc8cd3b5a21408edce4f0949d5b913e066b1"
],
[
[
"file",
"server/src/core/auth.js"
],
"968abe76a1cf4627b2bce2b9504558dbb1a8e5a50690262d6d4cd9506066ee72"
],
[
[
"file",
"server/src/core/auth/prismaMiddleware.js"
],
"aede440c2297aa5028e5f387e3392228d8249fb4906aeb95364006aeec039aae"
],
[
[
"file",
"server/src/dbClient.js"
],
"5fb53eff5e5eae318e281a8fc1cc433b69688d93c3c82d72415f710a6e75e2af"
],
[
[
"file",
"server/src/entities/index.ts"
],
"3dc318116ab03fc779f408442a2608011ea1f3d8f9a28109f27041b7d22ef3d1"
],
[
[
"file",
"server/src/ext-src/actions/bar.js"
],
"83c606a3eee7608155cdb2c2a20a38f851a82987e060ce25b196b467092c4740"
],
[
[
"file",
"server/src/ext-src/jobs/bar.js"
],
"83c606a3eee7608155cdb2c2a20a38f851a82987e060ce25b196b467092c4740"
],
[
[
"file",
"server/src/ext-src/myServerSetupCode.js"
],
"a5819016c8dd9e370e9ce89cd636a33c6eafa55f8fa38f31c3af453526f77cc2"
],
[
[
"file",
"server/src/ext-src/queries/bar.js"
],
"83c606a3eee7608155cdb2c2a20a38f851a82987e060ce25b196b467092c4740"
],
[
[
"file",
"server/src/jobs/MySpecialJob.js"
],
"9bf6a5f7005d3ab4ca933fb239ef21f13ba68aef30d8767230d6cc03911ca0e1"
],
[
[
"file",
"server/src/jobs/core/Job.js"
],
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
],
[
[
"file",
"server/src/jobs/core/SubmittedJob.js"
],
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
],
[
[
"file",
"server/src/jobs/core/allJobs.js"
],
"b5ddc268dfe8f1f7d96d775c1d8d407a647a45ed4937a87da7eb1eb50f9a4674"
],
[
[
"file",
"server/src/jobs/core/pgBoss/pgBoss.js"
],
"9821963d90b39058285343834c70e6f825d3d7696c738fd95539614b5e7d7b94"
],
[
[
"file",
"server/src/jobs/core/pgBoss/pgBossJob.js"
],
"ff6040d051c916eb080a2f2c37fd5135f588782387faeae51115d1a7abd1ad8b"
],
[
[
"file",
"server/src/jobs/core/simpleJob.js"
],
"36fe173d9f5128859196bfd3a661983df2d95eb34d165a469b840982b06cf59b"
],
[
[
"file",
"server/src/queries/MySpecialQuery.js"
],
"ecb3145ccbb1b4888db63b5cb000f4d65721d5b6ea99cc670d9f8faaddf0c51d"
],
[
[
"file",
"server/src/queries/types.ts"
],
"ddd91bb1d15b1c3e684726a6ea56d02c4bd103654b8c37373b943f64c12c0e29"
],
[
[
"file",
"server/src/routes/auth/index.js"
],
"ac6c9f57907b95529955248014d4a0b87d9883afba9f725e8bb6da7e9cdcbaef"
],
[
[
"file",
"server/src/routes/auth/login.js"
],
"0a5d88e1a91ceb6a893e286ed2e1167bf418ca9d226341d6ee6dc6c0fac54318"
],
[
[
"file",
"server/src/routes/auth/me.js"
],
"65ac0f6fe12e13ffeb67b01b62301c4c70e12707985bfa567aea0253649e9151"
],
[
[
"file",
"server/src/routes/auth/passport/generic/provider.js"
],
"bac53b5bb71b55593c97c18dcb26fe05c9fa3487dd7a63ee64940362100853ef"
],
[
[
"file",
"server/src/routes/auth/passport/google/config.js"
],
"40b271cec4039bcd0435e1766b0fdf6954caff6401db1272034848ea5bef97b3"
],
[
[
"file",
"server/src/routes/auth/passport/google/configMapping.js"
],
"bb20100bf23ee29e783f0d486f4b3fc75792742f4b92f9547c4b12216afebbb7"
],
[
[
"file",
"server/src/routes/auth/passport/google/defaults.js"
],
"e390f515ed581c51b75b43e716e346710c17fb1e99470f944de22fc22925a2e1"
],
[
[
"file",
"server/src/routes/auth/passport/passport.js"
],
"8798e7255db79cd528776cc9c72781950f69314277c6eff8b5320e6ffa722a2e"
],
[
[
"file",
"server/src/routes/auth/signup.js"
],
"7e1975a564b8d450f6c411753bfe4ab45d76b209bb28cfc86eb514236a018219"
],
[
[
"file",
"server/src/routes/auth/utils.js"
],
"c2752f5686d06db30aaefe7e7d2bae5e5d988bf9f34e04c262b73a69179521ae"
],
[
[
"file",
"server/src/routes/index.js"
],
"bd024209f256910dc8450851bf64d5309423e04f34bc5941cfb692df7907aa27"
],
[
[
"file",
"server/src/routes/operations/MySpecialAction.js"
],
"a811906270902af1259bffee936c9628e5aa3235cf9b08cc52b1acd54adebb2c"
],
[
[
"file",
"server/src/routes/operations/MySpecialQuery.js"
],
"5e4ef25a29bff0806cb2abef2e9ff8314232a5e505dbec0b9ace56d1c3369237"
],
[
[
"file",
"server/src/routes/operations/index.js"
],
"0cb1f33b2ddb9dbe6bb2640f899aa151b388e1799e97b28390af6404154f28c0"
],
[
[
"file",
"server/src/server.ts"
],
"e27f5f548ccf994e7a60c8e28b2eec80bb578d0da5c19cadb83b45745bc6fb84"
],
[
[
"file",
"server/src/types/index.ts"
],
"37352cdaeae444c90632fe788d1e49eca3927ef8755702ba95c81c8b90d9f408"
],
[
[
"file",
"server/src/universal/url.ts"
],
"8dc6e044a1a231b796465d94985ca47c5efd42a5d411b407a7d83a61ebae4b6d"
],
[
[
"file",
"server/src/utils.js"
],
"68a5794f55e24b303d81456a1181a3a2cd70773f6ebc4e7a63dac064834aa8e9"
],
[
[
"file",
"server/tsconfig.json"
],
"c58318dce36bc48fdd1e998af906b5026c87b1d80415653b03a7285acdb56d69"
],
[
[
"file",
"web-app/.gitignore"
],
"d373417ea2a8844e3b7a1515aab123e90fbeb4b7225144d55d243d9f6f04f73a"
],
[
[
"file",
"web-app/.npmrc"
],
"7151cf397def0c2cb0ab65643701d27d335a72c90f775675b5f826bc7005818a"
],
[
[
"file",
"web-app/README.md"
],
"aa1bd54732b015158d24c55e03bc79b2f211109bc2b0478d6f2ad6b52d15847f"
],
[
[
"file",
"web-app/index.html"
],
"f1ca90861003c568c2670b0064f15431cfc45a68b23ffd890b1187af588a76d9"
],
[
[
"file",
"web-app/netlify.toml"
],
"854009c7d1b2630a55099439f89e4947188ea4160c9d1388b46b5a5cf6ab461a"
],
[
[
"file",
"web-app/package.json"
],
"7076213d4eff673d37d271baa15cdda5cd5a647d31944015f52ef72aad558ac8"
],
[
[
"file",
"web-app/public/favicon.ico"
],
"1481f03584b46b63182c1f8d26e61a95ce4d8fae032b2b4f3fe5e00ab7e96c23"
],
[
[
"file",
"web-app/public/images/google-logo-icon.png"
],
"e2087f585c3b213ba537a56c8bc8e6134c69d6fa1a5728d306df56d697b4e7ab"
],
[
[
"file",
"web-app/public/manifest.json"
],
"b57981d1636058192ae6057166991125031f519f24c0a0711ef16dc73958e51a"
],
[
[
"file",
"web-app/scripts/universal/validators.mjs"
],
"94edf3906c43fb476b22244d07a5d69fd6c967de767532ed5b3215e2a6eee91b"
],
[
[
"file",
"web-app/scripts/validate-env.mjs"
],
"690460410bc9b28a8e96c6e20902e841f07b77faccf3db7e7249f48f700f4bab"
],
[
[
"file",
"web-app/src/actions/MySpecialAction.js"
],
"55e4031ed91e5dd8c1aaa5cf64e3295356938966f167c6950d81c9b7a77db787"
],
[
[
"file",
"web-app/src/actions/core.d.ts"
],
"d649b4823ede60119858b868dac9b122da4a3185391e6b9242ff7f6dc5c25d31"
],
[
[
"file",
"web-app/src/actions/core.js"
],
"5c4dcdec74fb014a8edbb3d240bcbbfc829e201bce64132598b444db14a2bd45"
],
[
[
"file",
"web-app/src/actions/index.ts"
],
"0f294c2f1d50a1473f6d332ef17944e7475c7d83a2180f6e2c9c9aecf25439f4"
],
[
[
"file",
"web-app/src/api.js"
],
"bcdb1fdc97fd622fbc882e4a16a7137e849748d6776b960121df6ccab577a3a9"
],
[
[
"file",
"web-app/src/auth/forms/Login.jsx"
],
"2a97bf6cb1ba5cd1f271dc83aaac5864a7e5738b72814c7d09bb3e5f70877686"
],
[
[
"file",
"web-app/src/auth/forms/Signup.jsx"
],
"712a1513116da68cef5a8dddcf80fbaffa8b733690c52e7c5cc1ef329af591e9"
],
[
[
"file",
"web-app/src/auth/helpers/Google.jsx"
],
"7707679f4ef70592429401cf932206b5cfd87dd80d244e3e8c05803658406228"
],
[
[
"file",
"web-app/src/auth/helpers/user.ts"
],
"7ae0fa01ac15b43746877b765bc4e6fd533f302c435b771238ad79afbe923000"
],
[
[
"file",
"web-app/src/auth/login.js"
],
"69b575be2d52e2948983f82c2d3fdabcc9d5fad9fd54c21252e9e7daa0edab32"
],
[
[
"file",
"web-app/src/auth/logout.js"
],
"041fe67384d24bf43a61b6ad9160b68309d8d1f5f3dd34f1961c93585d4eff50"
],
[
[
"file",
"web-app/src/auth/pages/OAuthCodeExchange.jsx"
],
"bb202daa3b41c8288252d56a74ed78bed7129d7c96be96171cd516795d0ff71b"
],
[
[
"file",
"web-app/src/auth/pages/createAuthRequiredPage.jsx"
],
"796720e403436139720fa72b941bc5fcf190b05777693c62d2b5f638e872badc"
],
[
[
"file",
"web-app/src/auth/signup.js"
],
"e2943a925604b86cb1eba6c8d5a99c6892d8d3b55940c656233aa1163a622a6d"
],
[
[
"file",
"web-app/src/auth/useAuth.js"
],
"6ab962413a3103fa4e2cb8f7d4751ee93da484f9312c007e0b4d28bfa130a367"
],
[
[
"file",
"web-app/src/config.js"
],
"b06175129dd8be8ca6c307fafa02646d47233c4e26fdfeea6d7802d02e9513f3"
],
[
[
"file",
"web-app/src/entities/index.ts"
],
"642ab14a4d5b92152ba458b210502adea70fa7a6d1d6af35108a71236a33e89b"
],
[
[
"file",
"web-app/src/ext-src/App.jsx"
],
"b23058c8652ddb88b8e147e20fb97c7e90c076b5224314f6a3ab4c620957c0ae"
],
[
[
"file",
"web-app/src/ext-src/Main.css"
],
"1583ce49887ea575e563dab0c7ad6d2e2e3e6a351ed7c2f80c310641b1456417"
],
[
[
"file",
"web-app/src/ext-src/MainPage.jsx"
],
"7244c106359f088fdcc0d4a76ee63277f1cea63cbe7aac5e5c39a17df693b1e2"
],
[
[
"file",
"web-app/src/ext-src/myClientSetupCode.js"
],
"32219bbb2a8d08dfa8447255b922c9d0d202d21a38e2fbc281b62b181b6f5134"
],
[
[
"file",
"web-app/src/ext-src/vite-env.d.ts"
],
"d677883c270ee71acc9bfd95c824650b541036599427a8ffc0ad33323d08e61d"
],
[
[
"file",
"web-app/src/ext-src/waspLogo.png"
],
"0f05a89eb945d6d7326110e88776e402833b356202b06d0a8bf652e118d3fd2f"
],
[
[
"file",
"web-app/src/index.tsx"
],
"bcb8202d7b6c536d1589aa5ec64407e6e856fd4b3866ec8e2111389fee2c751d"
],
[
[
"file",
"web-app/src/logo.png"
],
"35f14abf46cb0e9d9b4e3d11961917eecb86be65a0d7f4e6bc9713c5766b5f1b"
],
[
[
"file",
"web-app/src/operations/index.js"
],
"6ab717db2304b6134073aa71144b213b86f8d68a106549da06e193d18683dd87"
],
[
[
"file",
"web-app/src/operations/resources.js"
],
"8bb71b7d7ef3962c28b9db1554500452e7f91b0c9fd898d5cafcdb7229f6db1e"
],
[
[
"file",
"web-app/src/operations/updateHandlersMap.js"
],
"07a9933a771853585fd2fc49c3dcb9f1cad947acb2168580c835fae25620fa9f"
],
[
[
"file",
"web-app/src/queries/MySpecialQuery.js"
],
"277428b1579f92eb2fb04b323fa7b9c0acaad935d4f3a451c72694aee8605a32"
],
[
[
"file",
"web-app/src/queries/core.d.ts"
],
"f0b289140e92738451be386ca73a2fd1c84e9951eb2f1b9c6c09dfa3079d0c74"
],
[
[
"file",
"web-app/src/queries/core.js"
],
"2daf5b414722204281d65e954ce862a6fc586e8907b202800694909d23957c5e"
],
[
[
"file",
"web-app/src/queries/index.d.ts"
],
"efc70de9916a60e19e0c86aaf955b0be0c999ba5c30139c3b6b98bcc4d382091"
],
[
[
"file",
"web-app/src/queries/index.js"
],
"8379699fa710f43d5be2b96f3a5c7c551a8d3d9db3ef0fea964502da00f36ffc"
],
[
[
"file",
"web-app/src/queryClient.js"
],
"1739d8618286b4a2aa536bd3fcb754514c828ec896b69c7074e22f66c07e7b03"
],
[
[
"file",
"web-app/src/router.jsx"
],
"a92e5d20f1e2bbb7b4cbfae12e145338ddf34c38823c4b49c954da3c9b623157"
],
[
[
"file",
"web-app/src/universal/url.ts"
],
"8dc6e044a1a231b796465d94985ca47c5efd42a5d411b407a7d83a61ebae4b6d"
],
[
[
"file",
"web-app/src/utils.js"
],
"0ae6e53a050dbc6b7166aedfd5b076c2cab524c4d6aff884dc6902b178148815"
],
[
[
"file",
"web-app/src/vite-env.d.ts"
],
"65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
],
[
[
"file",
"web-app/tsconfig.json"
],
"49258557f92e10eddda507885655d22f51d52f6f39e277c2ec1c9d626018c728"
],
[
[
"file",
"web-app/tsconfig.node.json"
],
"c40d0614fda5b8a746a1ba2e3be8e2370140c0167851ac77881cd53bb5647cdd"
],
[
[
"file",
"web-app/vite.config.ts"
],
"ab5c138ab0ab01f5108c2da9a8bf6b673b79115ca97276f6293da8e3bc0a45b1"
]
]

View File

@ -0,0 +1,54 @@
# NOTE: Why do we specify alpine version here?
# Because if not, we had situations where it would use the different version
# locally and on Github CI. This way we ensure exact version is used,
# and also have control over updating it (instead of update surprising us).
FROM node:18-alpine3.17 AS node
# We split Dockerfile into base, server-builder and server-production.
# This way we have separate situations -> in server-builder we build all
# we need to run the server, and then in server-production we start fresh
# and just copy what we need from server-builder, avoiding intermediate
# artifacts and any settings / pollution we don't need in production
# but only for building.
FROM node AS base
RUN apk --no-cache -U upgrade # To ensure any potential security patches are applied.
# TODO: Remove line below (installation of openssl 1.1) once Prisma adds support for
# openssl 3 on alpine. Alpine >= 3.17 has openssl 3 as default.
# Relevant GH issue: https://github.com/wasp-lang/wasp/issues/877
RUN apk add --no-cache openssl1.1-compat
FROM base AS server-builder
RUN apk add --no-cache build-base libtool autoconf automake
WORKDIR /app
COPY server/ ./server/
# Install npm packages, resulting in node_modules/.
RUN cd server && npm install
COPY db/schema.prisma ./db/
RUN cd server && PRISMA_CLIENT_OUTPUT_DIR=../server/node_modules/.prisma/client/ npx prisma generate --schema='../db/schema.prisma'
# Building the server should come after Prisma generation.
RUN cd server && npm run build
# TODO: Use pm2?
# TODO: Use non-root user (node).
FROM base AS server-production
# In case they want to use python3 in their app.
RUN apk add --no-cache python3
ENV NODE_ENV production
WORKDIR /app
COPY --from=server-builder /app/server/node_modules ./server/node_modules
COPY --from=server-builder /app/server/dist ./server/dist
COPY --from=server-builder /app/server/package*.json ./server/
COPY --from=server-builder /app/server/scripts ./server/scripts
COPY db/ ./db/
EXPOSE ${PORT}
WORKDIR /app/server
ENTRYPOINT ["npm", "run", "start-production"]
# Any user-defined Dockerfile contents will be appended below.

View File

@ -0,0 +1,8 @@
{
"dependencies": {
"@prisma/client": "^4.5.0"
},
"devDependencies": {
"prisma": "^4.5.0"
}
}

View File

@ -0,0 +1,28 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
output = env("PRISMA_CLIENT_OUTPUT_DIR")
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
externalAuthAssociations SocialLogin[]
}
model SocialLogin {
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])
}

View File

@ -0,0 +1 @@
ca888a6ed14f8ad3980e4aa1c35d88a7268d8936134f78efc5a19add7abe9fc0

View File

@ -0,0 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.5.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.0.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.5.0"},{"name":"typescript","version":"^4.8.4"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.27.2"},{"name":"react","version":"^17.0.2"},{"name":"react-dom","version":"^17.0.2"},{"name":"@tanstack/react-query","version":"^4.13.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.5.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.1.0"},{"name":"typescript","version":"^4.9.3"},{"name":"@types/react","version":"^17.0.53"},{"name":"@types/react-dom","version":"^17.0.19"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^1.0.1"}]}}

View File

@ -0,0 +1,3 @@
GOOGLE_CLIENT_ID=google_client_id
GOOGLE_CLIENT_SECRET=google_client_secret

View File

@ -0,0 +1 @@
engine-strict=true

View File

@ -0,0 +1,5 @@
`npm start` to run server in development mode (it reloads on changes).
`npm run debug` to run `npm start` with debug logs enabled.
`npm run standard` to run StandardJS.

View File

@ -0,0 +1,10 @@
{
"env": {
"NODE_ENV": "development"
},
"watch": [
"src/",
".env"
],
"ext": "ts,mts,js,mjs,json"
}

View File

@ -0,0 +1,58 @@
{
"dependencies": {
"@prisma/client": "4.5.0",
"cookie-parser": "~1.4.6",
"cors": "^2.8.5",
"dotenv": "16.0.2",
"express": "~4.18.1",
"helmet": "^6.0.0",
"jsonwebtoken": "^8.5.1",
"lodash.merge": "^4.6.2",
"morgan": "~1.10.0",
"passport": "0.6.0",
"passport-google-oauth20": "2.0.0",
"patch-package": "^6.4.7",
"pg-boss": "^8.0.0",
"react-redux": "^7.1.3",
"redux": "^4.0.5",
"secure-password": "^4.0.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tsconfig/node18": "^1.0.1",
"@types/express": "^4.17.13",
"@types/node": "^18.11.9",
"nodemon": "^2.0.19",
"prisma": "4.5.0",
"standard": "^17.0.0",
"typescript": "^4.8.4"
},
"engineStrict": true,
"engines": {
"node": "^18.12.0"
},
"name": "server",
"nodemonConfig": {
"delay": "1000"
},
"overrides": {
"secure-password": {
"sodium-native": "3.3.0"
}
},
"private": true,
"scripts": {
"build": "npx tsc",
"build-and-start": "npm run build && npm run start",
"db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma",
"db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma",
"postinstall": "patch-package",
"standard": "standard",
"start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/server.js",
"start-production": "npm run db-migrate-prod && NODE_ENV=production npm run start",
"validate-env": "node -r dotenv/config ./scripts/validate-env.mjs",
"watch": "nodemon --exec 'npm run build-and-start || exit 1'"
},
"type": "module",
"version": "0.0.0"
}

View File

@ -0,0 +1,21 @@
diff --git a/node_modules/oauth/lib/oauth2.js b/node_modules/oauth/lib/oauth2.js
index 77241c4..b6c0184 100644
--- a/node_modules/oauth/lib/oauth2.js
+++ b/node_modules/oauth/lib/oauth2.js
@@ -158,8 +158,14 @@ exports.OAuth2.prototype._executeRequest= function( http_library, options, post_
});
});
request.on('error', function(e) {
- callbackCalled= true;
- callback(e);
+ // Ref: https://github.com/ciaranj/node-oauth/pull/363
+ // `www.googleapis.com` does `ECONNRESET` just after data is received in `passBackControl`
+ // this prevents the callback from being called twice, first in passBackControl and second time in here
+ // see also NodeJS Stream documentation: "The 'error' event may be emitted by a Readable implementation at any time"
+ if(!callbackCalled) {
+ callbackCalled= true;
+ callback(e);
+ }
});
if( (options.method == 'POST' || options.method == 'PUT') && post_body ) {

View File

@ -0,0 +1,21 @@
export function isValidAbsoluteURL(rawUrl) {
try {
const url = new URL(rawUrl);
/*
URL constructor will parse some invalid absolute URLs as valid URLs
so we need to do some extra checks.
Example: "localhost:3000" will be parsed as URL with protocol of
"localhost:" and host of "3000"
*/
return url.protocol === "http:" || url.protocol === "https:";
} catch (e) {
return false;
}
}
export function throwIfNotValidAbsoluteURL(value, name) {
if (value && !isValidAbsoluteURL(value)) {
throw new Error(`${name} must be a valid absolute URL`);
}
}

View File

@ -0,0 +1,4 @@
import { throwIfNotValidAbsoluteURL } from './universal/validators.mjs';
console.info("🔍 Validating environment variables...");
throwIfNotValidAbsoluteURL(process.env.WASP_WEB_CLIENT_URL, 'Environment variable WASP_WEB_CLIENT_URL');

View File

@ -0,0 +1,51 @@
import prisma from "../dbClient.js"
import { type User } from "../entities"
import { type _Entity } from "./taggedEntities"
export * from "./taggedEntities"
export type Query<Entities extends _Entity[], Input, Output> = Operation<Entities, Input, Output>
export type Action<Entities extends _Entity[], Input, Output> = Operation<Entities, Input, Output>
export type AuthenticatedQuery<Entities extends _Entity[], Input, Output> =
AuthenticatedOperation<Entities, Input, Output>
export type AuthenticatedAction<Entities extends _Entity[], Input, Output> =
AuthenticatedOperation<Entities, Input, Output>
type AuthenticatedOperation<Entities extends _Entity[], Input, Output> = (
args: Input,
context: Expand<OperationContext<Entities> & {
// TODO: This type must match the logic in core/auth.js (if we remove the
// password field from the object there, we must do the same here). Ideally,
// these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
user: Omit<User, 'password'>
}>,
) => Promise<Output>
type Operation<Entities extends _Entity[], Input, Output> = (
args: Input,
context: Expand<OperationContext<Entities>>,
) => Promise<Output>
type OperationContext<Entities extends _Entity[]> = {
entities: Expand<EntityMap<Entities>>
}
type EntityMap<Entities extends _Entity[]> = {
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
}
type PrismaDelegate = {
"User": typeof prisma.user,
"SocialLogin": typeof prisma.socialLogin,
}
// This is a helper type used exclusively for DX purposes. It's a No-op for the
// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
// type constructors) to make it more readable for the user.
//
// Check this SO answer for details: https://stackoverflow.com/a/57683652
type Expand<T extends object> = T extends infer O ? { [K in keyof O]: O[K] } : never

View File

@ -0,0 +1,22 @@
// Wasp internally uses the types defined in this file for typing entity maps in
// operation contexts.
//
// We must explicitly tag all entities with their name to avoid issues with
// structural typing. See https://github.com/wasp-lang/wasp/pull/982 for details.
import {
type Entity,
type EntityName,
type User,
type SocialLogin,
} from '../entities'
export type _User = WithName<User, "User">
export type _SocialLogin = WithName<SocialLogin, "SocialLogin">
export type _Entity =
| _User
| _SocialLogin
| never
type WithName<E extends Entity, Name extends EntityName> =
E & { _entityName: Name }

View File

@ -0,0 +1,13 @@
import prisma from '../dbClient.js'
import { foo } from '../ext-src/actions/bar.js'
export default async function (args, context) {
return foo(args, {
...context,
entities: {
User: prisma.user,
},
})
}

View File

@ -0,0 +1,14 @@
import {
type _User,
type AuthenticatedAction,
} from '../_types'
export type MySpecialAction<Input = never, Output = unknown> =
AuthenticatedAction<
[
_User,
],
Input,
Output
>

View File

@ -0,0 +1,41 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import logger from 'morgan'
import cors from 'cors'
import helmet from 'helmet'
import HttpError from './core/HttpError.js'
import indexRouter from './routes/index.js'
import config from './config.js'
// TODO: Consider extracting most of this logic into createApp(routes, path) function so that
// it can be used in unit tests to test each route individually.
const app = express()
app.use(helmet())
app.use(cors({
// TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup.
origin: config.frontendUrl,
}))
app.use(logger('dev'))
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())
app.use('/', indexRouter)
// Custom error handler.
app.use((err, req, res, next) => {
// As by expressjs documentation, when the headers have already
// been sent to the client, we must delegate to the default error handler.
if (res.headersSent) { return next(err) }
if (err instanceof HttpError) {
return res.status(err.statusCode).json({ message: err.message, data: err.data })
}
return next(err)
})
export default app

View File

@ -0,0 +1,37 @@
import merge from 'lodash.merge'
import { stripTrailingSlash } from "./universal/url.js";
const env = process.env.NODE_ENV || 'development'
// TODO:
// - Use dotenv library to consume env vars from a file.
// - Use convict library to define schema and validate env vars.
// https://codingsans.com/blog/node-config-best-practices
const config = {
all: {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
frontendUrl: undefined,
auth: {
jwtSecret: undefined
}
},
development: {
frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL) || 'http://localhost:3000',
auth: {
jwtSecret: 'DEVJWTSECRET'
}
},
production: {
frontendUrl: stripTrailingSlash(process.env.WASP_WEB_CLIENT_URL),
auth: {
jwtSecret: process.env.JWT_SECRET
}
}
}
const resolvedConfig = merge(config.all, config[env])
export default resolvedConfig

View File

@ -0,0 +1,17 @@
class AuthError extends Error {
constructor (message, data, ...params) {
super(message, ...params)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, AuthError)
}
this.name = this.constructor.name
if (data) {
this.data = data
}
}
}
export default AuthError

View File

@ -0,0 +1,22 @@
class HttpError extends Error {
constructor (statusCode, message, data, ...params) {
super(message, ...params)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, HttpError)
}
this.name = this.constructor.name
if (!(Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600)) {
throw new Error('statusCode has to be integer in range [400, 600).')
}
this.statusCode = statusCode
if (data) {
this.data = data
}
}
}
export default HttpError

View File

@ -0,0 +1,124 @@
import jwt from 'jsonwebtoken'
import SecurePassword from 'secure-password'
import util from 'util'
import { randomInt } from 'node:crypto'
import prisma from '../dbClient.js'
import { handleRejection } from '../utils.js'
import config from '../config.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const sign = (id, options) => jwtSign({ id }, JWT_SECRET, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)
const auth = handleRejection(async (req, res, next) => {
const authHeader = req.get('Authorization')
if (!authHeader) {
// NOTE(matija): for now we let tokenless requests through and make it operation's
// responsibility to verify whether the request is authenticated or not. In the future
// we will develop our own system at Wasp-level for that.
return next()
}
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length)
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
return res.status(401).send()
} else {
throw error
}
}
const user = await prisma.user.findUnique({ where: { id: userIdFromToken } })
if (!user) {
return res.status(401).send()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const { password, ...userView } = user
req.user = userView
} else {
return res.status(401).send()
}
next()
})
const SP = new SecurePassword()
export const hashPassword = async (password) => {
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
return hashedPwdBuffer.toString("base64")
}
export const verifyPassword = async (hashedPassword, password) => {
try {
return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
} catch (error) {
console.error(error)
return false
}
}
// Generates an unused username that looks similar to "quick-purple-sheep-91231".
// It generates several options and ensures it picks one that is not currently in use.
export function generateAvailableDictionaryUsername() {
const adjectives = ['fuzzy', 'tall', 'short', 'nice', 'happy', 'quick', 'slow', 'good', 'new', 'old', 'first', 'last', 'old', 'young']
const colors = ['red', 'green', 'blue', 'white', 'black', 'brown', 'purple', 'orange', 'yellow']
const nouns = ['wasp', 'cat', 'dog', 'lion', 'rabbit', 'duck', 'pig', 'bee', 'goat', 'crab', 'fish', 'chicken', 'horse', 'llama', 'camel', 'sheep']
const potentialUsernames = []
for (let i = 0; i < 10; i++) {
const potentialUsername = `${adjectives[randomInt(adjectives.length)]}-${colors[randomInt(colors.length)]}-${nouns[randomInt(nouns.length)]}-${randomInt(100_000)}`
potentialUsernames.push(potentialUsername)
}
return findAvailableUsername(potentialUsernames)
}
// Generates an unused username based on an array of username segments and a separator.
// It generates several options and ensures it picks one that is not currently in use.
export function generateAvailableUsername(usernameSegments, config) {
const separator = config?.separator || '-'
const baseUsername = usernameSegments.join(separator)
const potentialUsernames = []
for (let i = 0; i < 10; i++) {
const potentialUsername = `${baseUsername}${separator}${randomInt(100_000)}`
potentialUsernames.push(potentialUsername)
}
return findAvailableUsername(potentialUsernames)
}
// Checks the database for an unused username from an array provided and returns first.
async function findAvailableUsername(potentialUsernames) {
const users = await prisma.user.findMany({
where: {
username: { in: potentialUsernames },
}
})
const takenUsernames = users.map(user => user.username)
const availableUsernames = potentialUsernames.filter(username => !takenUsernames.includes(username))
if (availableUsernames.length === 0) {
throw new Error('Unable to generate a unique username. Please contact Wasp.')
}
return availableUsernames[0]
}
export default auth

View File

@ -0,0 +1,85 @@
import { hashPassword } from '../auth.js'
import AuthError from '../AuthError.js'
const USERNAME_FIELD = 'username'
const PASSWORD_FIELD = 'password'
// Allows flexible validation of a user entity.
// Users can skip default validations by passing _waspSkipDefaultValidations = true
// Users can also add custom validations by passing an array of _waspCustomValidations
// with the same format as our default validations.
// Throws an AuthError on the first validation that fails.
const registerUserEntityValidation = (prismaClient) => {
prismaClient.$use(async (params, next) => {
if (params.model === 'User') {
if (['create', 'update', 'updateMany'].includes(params.action)) {
validateUser(params.args.data, params.args, params.action)
} else if (params.action === 'upsert') {
validateUser(params.args.create, params.args, 'create')
validateUser(params.args.update, params.args, 'update')
}
// Remove from downstream Prisma processing to avoid "Unknown arg" error
delete params.args._waspSkipDefaultValidations
delete params.args._waspCustomValidations
}
return next(params)
})
}
// 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])
}
}
}
return next(params)
})
}
export const registerAuthMiddleware = (prismaClient) => {
// NOTE: registerUserEntityValidation must come before registerPasswordHashing.
registerUserEntityValidation(prismaClient)
registerPasswordHashing(prismaClient)
}
const validateUser = (user, args, action) => {
user = user || {}
const defaultValidations = [
{ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username },
{ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password },
{ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => password.length >= 8 },
{ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => /\d/.test(password) },
]
const validations = [
...(args._waspSkipDefaultValidations ? [] : defaultValidations),
...(args._waspCustomValidations || [])
]
// On 'create' validations run always, otherwise (on updates)
// they run only when the field they are validating is present.
for (const v of validations) {
if (action === 'create' || user.hasOwnProperty(v.validates)) {
if (!v.validator(user[v.validates])) {
throw new AuthError(v.message)
}
}
}
}

View File

@ -0,0 +1,19 @@
import Prisma from '@prisma/client'
import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js'
const createDbClient = () => {
const prismaClient = new Prisma.PrismaClient()
registerAuthMiddleware(prismaClient)
return prismaClient
}
const dbClient = createDbClient()
export default dbClient

View File

@ -0,0 +1,19 @@
import {
type User,
type SocialLogin,
} from "@prisma/client"
export {
type User,
type SocialLogin,
} from "@prisma/client"
export type Entity =
| User
| SocialLogin
| never
export type EntityName =
| "User"
| "SocialLogin"
| never

View File

@ -0,0 +1,4 @@
export const foo = async (args) => {
return 1
}

View File

@ -0,0 +1,4 @@
export const foo = async (args) => {
return 1
}

View File

@ -0,0 +1,4 @@
export default function mySetupFunction() {
// Do some server setup here.
}

View File

@ -0,0 +1,4 @@
export const foo = async (args) => {
return 1
}

View File

@ -0,0 +1,12 @@
import prisma from '../dbClient.js'
import { createJob } from './core/pgBoss/pgBossJob.js'
import { foo } from '../ext-src/jobs/bar.js'
export const MySpecialJob = createJob({
jobName: "MySpecialJob",
jobFn: foo,
defaultJobOptions: {},
jobSchedule: null,
entities: {
},
})

View File

@ -0,0 +1,36 @@
/**
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
* This can be submitted one or more times to be executed by some job executor via the same instance.
* Once submitted, you get a SubmittedJob to track it later.
*/
export class Job {
#jobName
#executorName
/**
* @param {string} jobName - Job name, which should be unique per executor.
* @param {string} executorName - The name of the executor that will run submitted jobs.
*/
constructor(jobName, executorName) {
this.#jobName = jobName
this.#executorName = executorName
}
get jobName() {
return this.#jobName
}
get executorName() {
return this.#executorName
}
// NOTE: Subclasses must implement this method.
delay(...args) {
throw new Error('Subclasses must implement this method')
}
// NOTE: Subclasses must implement this method.
async submit(...args) {
throw new Error('Subclasses must implement this method')
}
}

View File

@ -0,0 +1,29 @@
/**
* This is the result of submitting a Job to some executor.
* It can be used by callers to track things, or call executor-specific subclass functionality.
*/
export class SubmittedJob {
#job
#jobId
/**
* @param {Job} job - The Job that submitted work to an executor.
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
*/
constructor(job, jobId) {
this.#job = job
this.#jobId = jobId
}
get jobId() {
return this.#jobId
}
get jobName() {
return this.#job.jobName
}
get executorName() {
return this.#job.executorName
}
}

View File

@ -0,0 +1,5 @@
// This module exports all jobs and is imported by the server to ensure
// any schedules that are not referenced are still loaded by NodeJS.
export { MySpecialJob } from '../MySpecialJob.js'

View File

@ -0,0 +1,69 @@
import PgBoss from 'pg-boss'
import config from '../../../config.js'
const boss = createPgBoss()
function createPgBoss() {
let pgBossNewOptions = {
connectionString: config.databaseUrl,
}
// Add an escape hatch for advanced configuration of pg-boss to overwrite our defaults.
if (process.env.PG_BOSS_NEW_OPTIONS) {
try {
pgBossNewOptions = JSON.parse(process.env.PG_BOSS_NEW_OPTIONS)
}
catch {
console.error("Environment variable PG_BOSS_NEW_OPTIONS was not parsable by JSON.parse()!")
}
}
return new PgBoss(pgBossNewOptions)
}
let resolvePgBossStarted, rejectPgBossStarted
// Code that wants to access pg-boss must wait until it has been started.
export const pgBossStarted = new Promise((resolve, reject) => {
resolvePgBossStarted = resolve
rejectPgBossStarted = reject
})
// Ensure pg-boss can only be started once during a server's lifetime.
const PgBossStatus = {
Unstarted: 'Unstarted',
Starting: 'Starting',
Started: 'Started',
Error: 'Error'
}
let pgBossStatus = PgBossStatus.Unstarted
/**
* Prepares the target PostgreSQL database and begins job monitoring.
* If the required database objects do not exist in the specified database,
* `boss.start()` will automatically create them.
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
*
* After making this call, we can send pg-boss jobs and they will be persisted and acted upon.
* This should only be called once during a server's lifetime.
*/
export async function startPgBoss() {
if (pgBossStatus !== PgBossStatus.Unstarted) { return }
pgBossStatus = PgBossStatus.Starting
console.log('Starting pg-boss...')
boss.on('error', error => console.error(error))
try {
await boss.start()
} catch (error) {
console.error('pg-boss failed to start!')
console.error(error)
pgBossStatus = PgBossStatus.Error
rejectPgBossStarted(boss)
return
}
resolvePgBossStarted(boss)
console.log('pg-boss started!')
pgBossStatus = PgBossStatus.Started
}

View File

@ -0,0 +1,120 @@
import { pgBossStarted } from './pgBoss.js'
import { Job } from '../Job.js'
import { SubmittedJob } from '../SubmittedJob.js'
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
/**
* A pg-boss specific SubmittedJob that adds additional pg-boss functionality.
*/
class PgBossSubmittedJob extends SubmittedJob {
constructor(boss, job, jobId) {
super(job, jobId)
this.pgBoss = {
cancel: () => boss.cancel(jobId),
resume: () => boss.resume(jobId),
details: () => boss.getJobById(jobId),
}
}
}
/**
* This is a class repesenting a job that can be submitted to pg-boss.
* It is not yet submitted until the caller invokes `submit()` on an instance.
* The caller can make as many calls to `submit()` as they wish.
*/
class PgBossJob extends Job {
#defaultJobOptions
#startAfter
/**
*
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
*/
constructor(jobName, defaultJobOptions, startAfter = undefined) {
super(jobName, PG_BOSS_EXECUTOR_NAME)
this.#defaultJobOptions = defaultJobOptions
this.#startAfter = startAfter
}
/**
* @param {int | string | date} startAfter - Defers job execution by either:
* - int: Seconds to delay starting the job [Default: 0]
* - string: Start after a UTC Date time string in 8601 format
* - Date: Start after a Date object
*/
delay(startAfter) {
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
}
/**
* Submits the job to pg-boss.
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
* @param {object} jobOptions - pg-boss specific options for `boss.send()`, which can override their defaultJobOptions.
*/
async submit(jobArgs, jobOptions = {}) {
const boss = await pgBossStarted
const jobId = await boss.send(this.jobName, jobArgs,
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
return new PgBossSubmittedJob(boss, this, jobId)
}
}
/**
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
* We expect this to be called once per job name. If called multiple times with the same name and different
* functions, we will override the previous calls.
* @param {string} jobName - The user-defined job name in their .wasp file.
* @param {fn} jobFn - The user-defined async job callback function.
* @param {object} defaultJobOptions - pg-boss specific options for `boss.send()` applied to every `submit()` invocation,
* which can overriden in that call.
* @param {object} jobSchedule [Optional] - The 5-field cron string, job function JSON arg, and `boss.send()` options when invoking the job.
* @param {array} entities - Entities used by job, passed into callback context.
*/
export function createJob({ jobName, jobFn, defaultJobOptions, jobSchedule, entities } = {}) {
// NOTE(shayne): We are not awaiting `pgBossStarted` here since we need to return an instance to the job
// template, or else the NodeJS module bootstrapping process will block and fail as it would then depend
// on a runtime resolution of the promise in `startServer()`.
// Since `pgBossStarted` will resolve in the future, it may appear possible to send pg-boss
// a job before we actually have registered the handler via `boss.work()`. However, even if NodeJS does
// not execute this callback before any job `submit()` calls, this is not a problem since pg-boss allows you
// to submit jobs even if there are no workers registered.
// Once they are registered, they will just start on the first job in their queue.
pgBossStarted.then(async (boss) => {
// As a safety precaution against undefined behavior of registering different
// functions for the same job name, remove all registered functions first.
await boss.offWork(jobName)
// This tells pg-boss to run given worker function when job with that name is submitted.
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
await boss.work(jobName, pgBossCallbackWrapper(jobFn, entities))
// If a job schedule is provided, we should schedule the recurring job.
// If the schedule name already exists, it's updated to the provided cron expression, arguments, and options.
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#scheduling
if (jobSchedule) {
const options = { ...defaultJobOptions, ...jobSchedule.options }
await boss.schedule(jobName, jobSchedule.cron, jobSchedule.args || null, options)
}
})
return new PgBossJob(jobName, defaultJobOptions)
}
/**
* Wraps the normal pg-boss callback function to inject entities, as well as extract
* the `data` property so the arguments passed into the job are the exact same as those received.
*
* @param {fn} jobFn - The user-defined async job callback function.
* @param {array} entities - Entities used by job, passed into callback context.
* @returns a function that accepts the pg-boss callback arguments and invokes the user-defined callback.
*/
function pgBossCallbackWrapper(jobFn, entities) {
return (args) => {
const context = { entities }
return jobFn(args.data, context)
}
}

View File

@ -0,0 +1,47 @@
import { sleep } from '../../utils.js'
import { Job } from './Job.js'
import { SubmittedJob } from './SubmittedJob.js'
export const SIMPLE_EXECUTOR_NAME = Symbol('Simple')
/**
* A simple job mainly intended for testing. It will not submit work to any
* job executor, but instead will simply invoke the underlying perform function.
* It does not support `schedule`. It does not require any extra NPM dependencies
* or infrastructure, however.
*/
class SimpleJob extends Job {
#jobFn
#delaySeconds
/**
*
* @param {string} jobName - Name of the Job.
* @param {fn} jobFn - The Job function to execute.
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
*/
constructor(jobName, jobFn, delaySeconds = 0) {
super(jobName, SIMPLE_EXECUTOR_NAME)
this.#jobFn = jobFn
this.#delaySeconds = delaySeconds
}
/**
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
*/
delay(delaySeconds) {
return new SimpleJob(this.jobName, this.#jobFn, delaySeconds)
}
async submit(jobArgs) {
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
// as a dependency in the server generator for something nobody will likely use.
const jobId = (Math.random() + 1).toString(36).substring(7)
return new SubmittedJob(this, jobId)
}
}
export function createJob({ jobName, jobFn } = {}) {
return new SimpleJob(jobName, jobFn)
}

View File

@ -0,0 +1,13 @@
import prisma from '../dbClient.js'
import { foo } from '../ext-src/queries/bar.js'
export default async function (args, context) {
return foo(args, {
...context,
entities: {
User: prisma.user,
},
})
}

View File

@ -0,0 +1,15 @@
import {
type _User,
type AuthenticatedQuery,
} from '../_types'
export type MySpecialQuery<Input = never, Output = unknown> =
AuthenticatedQuery<
[
_User,
],
Input,
Output
>

View File

@ -0,0 +1,18 @@
import express from 'express'
import auth from '../../core/auth.js'
import login from './login.js'
import signup from './signup.js'
import me from './me.js'
import passportAuth from './passport/passport.js'
const router = express.Router()
router.post('/login', login)
router.post('/signup', signup)
router.get('/me', auth, me)
router.use('/external', passportAuth)
export default router

View File

@ -0,0 +1,40 @@
import Prisma from '@prisma/client'
import SecurePassword from 'secure-password'
import { sign, verifyPassword } from '../../core/auth.js'
import { handleRejection } from '../../utils.js'
const prisma = new Prisma.PrismaClient()
export default handleRejection(async (req, res) => {
const args = req.body || {}
const context = {}
// Try to fetch user with the given username.
const user = await prisma.user.findUnique({ where: { username: args.username } })
if (!user) {
return res.status(401).send()
}
// We got user - now check the password.
const verifyPassRes = await verifyPassword(user.password, args.password)
switch (verifyPassRes) {
case SecurePassword.VALID:
break
case SecurePassword.VALID_NEEDS_REHASH:
// TODO(matija): take neccessary steps to make the password more secure.
break
default:
return res.status(401).send()
}
// Username & password valid - generate token.
const token = await sign(user.id)
// 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 })
})

View File

@ -0,0 +1,9 @@
import { handleRejection } from '../../utils.js'
export default handleRejection(async (req, res) => {
if (req.user) {
return res.json(req.user)
} else {
return res.status(401).send()
}
})

View File

@ -0,0 +1,62 @@
import express from 'express'
import passport from 'passport'
import waspServerConfig from '../../../../config.js'
import { contextWithUserEntity, authConfig, findOrCreateUserByExternalAuthAssociation } from '../../utils.js'
import { sign } from '../../../../core/auth.js'
// This function is invoked after we successfully exchange the one-time-use OAuth code for a real provider API token.
// This token was used to get the provider profile information supplied as a parameter.
// We add the provider profile to the request for downstream use.
async function addProviderProfileToRequest(req, _accessToken, _refreshToken, providerProfile, done) {
req.wasp = { ...req.wasp, providerProfile }
done(null, {})
}
export function initRouter(providerName, ProviderStrategy, config, getUserFieldsFn) {
// Configure and use Passport.js strategy.
const passportStrategyName = `wasp${providerName}LoginStrategy`
const requiredConfig = {
callbackURL: `${waspServerConfig.frontendUrl}/auth/login/${providerName}`,
passReqToCallback: true
}
const passportStrategy = new ProviderStrategy({ ...config, ...requiredConfig }, addProviderProfileToRequest)
passport.use(passportStrategyName, passportStrategy)
// Create a new router to use the Passport.js strategy.
const router = express.Router()
// Constructs a provider OAuth URL and redirects browser to start sign in flow.
router.get('/login', passport.authenticate(passportStrategyName, { session: false }))
// Validates the OAuth code from the frontend, via server-to-server communication
// with provider. If valid, provides frontend a response containing the JWT.
// NOTE: `addProviderProfileToRequest` is invoked as part of the `passport.authenticate`
// call, before the final route handler callback. This is how we gain access to `req.wasp.providerProfile`.
router.get('/validateCodeForLogin',
passport.authenticate(passportStrategyName, {
session: false,
failureRedirect: waspServerConfig.frontendUrl + authConfig.failureRedirectPath
}),
async function (req, res) {
const providerProfile = req?.wasp?.providerProfile
if (!providerProfile) {
throw new Error(`Missing ${providerName} provider profile on request. This should not happen! Please contact Wasp.`)
} else if (!providerProfile.id) {
throw new Error(`${providerName} provider profile was missing required id property. This should not happen! Please contact Wasp.`)
}
// Wrap call to getUserFieldsFn so we can invoke only if needed.
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(providerName, providerProfile.id, getUserFields)
const token = await sign(user.id)
res.json({ token })
})
return router
}

View File

@ -0,0 +1,21 @@
import { configFn } from './configMapping.js'
export { getUserFieldsFn } from './configMapping.js'
// Validates the provided config function returns all required data.
export const config = ((config) => {
if (!config?.clientID) {
throw new Error("The Google configFn must return an object with a clientID property.")
}
if (!config?.clientSecret) {
throw new Error("The Google configFn must return an object with a clientSecret property.")
}
if (!config?.scope) {
throw new Error("The Google configFn must return an object with a scope property.")
} else if (!Array.isArray(config.scope) || !config.scope.includes('profile')) {
throw new Error("The Google configFn returned an object with an invalid scope property. It must be an array including 'profile'.")
}
return config
})(await configFn())

View File

@ -0,0 +1,4 @@
export { configFn } from './defaults.js'
export { getUserFieldsFn } from './defaults.js'

View File

@ -0,0 +1,23 @@
import { generateAvailableDictionaryUsername } from '../../../../core/auth.js'
// Default implementation if there is no `auth.methods.google.configFn`.
export function configFn() {
const clientID = process.env['GOOGLE_CLIENT_ID']
const clientSecret = process.env['GOOGLE_CLIENT_SECRET']
if (!clientID) {
throw new Error("Missing GOOGLE_CLIENT_ID environment variable.")
}
if (!clientSecret) {
throw new Error("Missing GOOGLE_CLIENT_SECRET environment variable.")
}
return { clientID, clientSecret, scope: ['profile'] }
}
// Default implementation if there is no `auth.methods.google.getUserFieldsFn`.
export async function getUserFieldsFn(_context, _args) {
const username = await generateAvailableDictionaryUsername()
return { username }
}

View File

@ -0,0 +1,19 @@
import express from 'express'
import { initRouter } from './generic/provider.js'
const providerMap = new Map();
providerMap.set('google', { npmPackage: 'passport-google-oauth20', passportImportPath: './google/config.js' })
const router = express.Router()
async function initProviders(providers) {
for (let [providerSlug, { npmPackage, passportImportPath }] of providers) {
const { config, getUserFieldsFn } = await import(passportImportPath)
const ProviderStrategy = await import(npmPackage)
router.use(`/${providerSlug}`, initRouter(providerSlug, ProviderStrategy.default, config, getUserFieldsFn))
}
}
await initProviders(providerMap)
export default router

View File

@ -0,0 +1,22 @@
import prisma from '../../dbClient.js'
import { handleRejection, isPrismaError, prismaErrorToHttpError } from '../../utils.js'
import AuthError from '../../core/AuthError.js'
import HttpError from '../../core/HttpError.js'
export default handleRejection(async (req, res) => {
const userFields = req.body || {}
try {
await prisma.user.create({ data: userFields })
} catch (e) {
if (e instanceof AuthError) {
throw new HttpError(422, 'Validation failed', { message: e.message })
} else if (isPrismaError(e)) {
throw prismaErrorToHttpError(e)
} else {
throw new HttpError(500)
}
}
res.send()
})

View File

@ -0,0 +1,41 @@
import { v4 as uuidv4 } from 'uuid'
import prisma from '../../dbClient.js'
export const contextWithUserEntity = {
entities: {
User: prisma.user
}
}
export const authConfig = {
failureRedirectPath: "/login",
successRedirectPath: "/",
}
export async function findOrCreateUserByExternalAuthAssociation(provider, providerId, getUserFields) {
// Attempt to find a User by an external auth association.
const externalAuthAssociation = await prisma.socialLogin.findFirst({
where: { provider, providerId },
include: { user: true }
})
if (externalAuthAssociation) {
return externalAuthAssociation.user
}
// No external auth association linkage found. Create a new User using details from
// `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User.
// NOTE: For now, we force a random (uuidv4) password string. In the future, we will allow password reset.
const userFields = await getUserFields()
const userAndExternalAuthAssociation = {
...userFields,
password: uuidv4(),
externalAuthAssociations: {
create: [{ provider, providerId }]
}
}
return await prisma.user.create({ data: userAndExternalAuthAssociation })
}

View File

@ -0,0 +1,15 @@
import express from 'express'
import operations from './operations/index.js'
import auth from './auth/index.js'
const router = express.Router()
router.get('/', function (req, res, next) {
res.json('Hello world')
})
router.use('/auth', auth)
router.use('/operations', operations)
export default router

View File

@ -0,0 +1,14 @@
import { handleRejection } from '../../utils.js'
import MySpecialAction from '../../actions/MySpecialAction.js'
export default handleRejection(async (req, res) => {
const args = req.body || {}
const context = {
user: req.user
}
const result = await MySpecialAction(args, context)
res.json(result)
})

View File

@ -0,0 +1,14 @@
import { handleRejection } from '../../utils.js'
import MySpecialQuery from '../../queries/MySpecialQuery.js'
export default handleRejection(async (req, res) => {
const args = req.body || {}
const context = {
user: req.user
}
const result = await MySpecialQuery(args, context)
res.json(result)
})

View File

@ -0,0 +1,13 @@
import express from 'express'
import auth from '../../core/auth.js'
import MySpecialAction from './MySpecialAction.js'
import MySpecialQuery from './MySpecialQuery.js'
const router = express.Router()
router.post('/my-special-action', auth, MySpecialAction)
router.post('/my-special-query', auth, MySpecialQuery)
export default router

View File

@ -0,0 +1,58 @@
import http from 'http'
import app from './app.js'
import config from './config.js'
import mySetupFunction from './ext-src/myServerSetupCode.js'
import { ServerSetupFnContext } from './types'
import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
import './jobs/core/allJobs.js'
const startServer = async () => {
await startPgBoss()
const port = normalizePort(config.port)
app.set('port', port)
const server = http.createServer(app)
const serverSetupFnContext: ServerSetupFnContext = { app, server }
await mySetupFunction(serverSetupFnContext)
server.listen(port)
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.syscall !== 'listen') throw error
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges')
process.exit(1)
case 'EADDRINUSE':
console.error(bind + ' is already in use')
process.exit(1)
default:
throw error
}
})
server.on('listening', () => {
const addr = server.address()
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
console.log('Server listening on ' + bind)
})
}
startServer().catch(e => console.error(e))
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort (val) {
const port = parseInt(val, 10)
if (isNaN(port)) return val // named pipe
if (port >= 0) return port // port number
return false
}

View File

@ -0,0 +1,12 @@
import { Application } from 'express'
import { Server } from 'http'
export type ServerSetupFn = (context: ServerSetupFnContext) => Promise<void>
export type ServerSetupFnContext = {
app: Application,
server: Server,
}
export { Application } from 'express'
export { Server } from 'http'

View File

@ -0,0 +1,3 @@
export function stripTrailingSlash(url?: string): string | undefined {
return url?.replace(/\/$/, "");
}

View File

@ -0,0 +1,47 @@
import Prisma from '@prisma/client'
import HttpError from './core/HttpError.js'
/**
* Decorator for async express middleware that handles promise rejections.
* @param {Func} middleware - Express middleware function.
* @returns {Func} Express middleware that is exactly the same as the given middleware but,
* if given middleware returns promise, reject of that promise will be correctly handled,
* meaning that error will be forwarded to next().
*/
export const handleRejection = (middleware) => async (req, res, next) => {
try {
await middleware(req, res, next)
} catch (error) {
next(error)
}
}
export const isPrismaError = (e) => {
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) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
target: e.meta.target
})
} else {
// TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes
// and decide which are input errors (422) and which are not (500)
// See: https://github.com/wasp-lang/wasp/issues/384
return new HttpError(500)
}
} else if (e instanceof Prisma.PrismaClientValidationError) {
return new HttpError(422, 'Save failed')
} else {
return new HttpError(500)
}
}
export const sleep = ms => new Promise(r => setTimeout(r, ms))

View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
// Overriding this until we implement more complete TypeScript support.
"strict": false,
// Overriding this because we want to use top-level await
"module": "esnext",
// Enable source map for debugging
"sourceMap": true,
// The remaining settings should match node18/tsconfig.json, but I kept
// them here to be explicit.
// Enable default imports in TypeScript.
"esModuleInterop": true,
"moduleResolution": "node",
"outDir": "dist",
"allowJs": true
},
"include": ["src"]
}

View File

@ -0,0 +1 @@
engine-strict=true

View File

@ -0,0 +1,68 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
### Analyzing the Bundle Size
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
### Making a Progressive Web App
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
### Advanced Configuration
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
### Deployment
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
### `npm run build` fails to minify
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="/manifest.json" />
<title>waspComplexTest</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,8 @@
[build]
publish = "build/"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false

View File

@ -0,0 +1,47 @@
{
"browserslist": {
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
],
"production": [
">0.2%",
"not dead",
"not op_mini all"
]
},
"dependencies": {
"@prisma/client": "4.5.0",
"@tanstack/react-query": "^4.13.0",
"axios": "^0.27.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.1.3",
"react-router-dom": "^5.3.3",
"redux": "^4.0.5"
},
"devDependencies": {
"@tsconfig/vite-react": "^1.0.1",
"@types/react": "^17.0.53",
"@types/react-dom": "^17.0.19",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.0.0",
"dotenv": "^16.0.3",
"typescript": "^4.9.3",
"vite": "^4.1.0"
},
"engineStrict": true,
"engines": {
"node": "^18.12.0"
},
"name": "waspComplexTest",
"private": true,
"scripts": {
"build": "npm run validate-env && tsc && vite build",
"start": "npm run validate-env && vite",
"validate-env": "node -r dotenv/config ./scripts/validate-env.mjs"
},
"type": "module",
"version": "0.0.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,14 @@
{
"name": "waspComplexTest",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,21 @@
export function isValidAbsoluteURL(rawUrl) {
try {
const url = new URL(rawUrl);
/*
URL constructor will parse some invalid absolute URLs as valid URLs
so we need to do some extra checks.
Example: "localhost:3000" will be parsed as URL with protocol of
"localhost:" and host of "3000"
*/
return url.protocol === "http:" || url.protocol === "https:";
} catch (e) {
return false;
}
}
export function throwIfNotValidAbsoluteURL(value, name) {
if (value && !isValidAbsoluteURL(value)) {
throw new Error(`${name} must be a valid absolute URL`);
}
}

View File

@ -0,0 +1,4 @@
import { throwIfNotValidAbsoluteURL } from './universal/validators.mjs';
console.info("🔍 Validating environment variables...");
throwIfNotValidAbsoluteURL(process.env.REACT_APP_API_URL, 'Environemnt variable REACT_APP_API_URL');

View File

@ -0,0 +1,6 @@
import { createAction } from './core'
export default createAction(
'operations/my-special-action',
['User'],
)

View File

@ -0,0 +1,3 @@
import { Action } from '.'
export function createAction<Input, Output>(actionRoute: string, entitiesUsed: unknown[]): Action<Input, Output>

View File

@ -0,0 +1,33 @@
import { callOperation } from '../operations'
import {
registerActionInProgress,
registerActionDone,
} from '../operations/resources'
export function createAction(actionRoute, entitiesUsed) {
async function internalAction(args, specificOptimisticUpdateDefinitions) {
registerActionInProgress(specificOptimisticUpdateDefinitions)
try {
// The `return await` is not redundant here. If we removed the await, the
// `finally` block would execute before the action finishes, prematurely
// registering the action as done.
return await callOperation(actionRoute, args)
} finally {
await registerActionDone(entitiesUsed, specificOptimisticUpdateDefinitions)
}
}
// We expose (and document) a restricted version of the API for our users,
// while also attaching the full "internal" API to the exposed action. By
// doing this, we can easily use the internal API of an action a users passes
// into our system (e.g., through the `useAction` hook) without needing a
// lookup table.
//
// While it does technically allow our users to access the interal API, it
// shouldn't be a problem in practice. Still, if it turns out to be a problem,
// we can always hide it using a Symbol.
const action = (args) => internalAction(args, [])
action.internal = internalAction
return action
}

View File

@ -0,0 +1,266 @@
import {
QueryClient,
QueryKey,
useMutation,
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query'
import { Query } from '../queries';
export type Action<Input, Output> = (args?: Input) => Promise<Output>;
/**
* An options object passed into the `useAction` hook and used to enhance the
* action with extra options.
*
*/
export type ActionOptions<ActionInput> = {
optimisticUpdates: OptimisticUpdateDefinition<ActionInput, any>[]
}
/**
* A documented (public) way to define optimistic updates.
*/
export type OptimisticUpdateDefinition<ActionInput, CachedData> = {
getQuerySpecifier: GetQuerySpecifier<ActionInput, CachedData>
updateQuery: UpdateQuery<ActionInput, CachedData>
}
/**
* A function that takes an item and returns a Wasp Query specifier.
*/
export type GetQuerySpecifier<ActionInput, CachedData> = (item: ActionInput) => QuerySpecifier<unknown, CachedData>
/**
* A function that takes an item and the previous state of the cache, and returns
* the desired (new) state of the cache.
*/
export type UpdateQuery<ActionInput, CachedData> = (item: ActionInput, oldData: CachedData | undefined) => CachedData
/**
* A public query specifier used for addressing Wasp queries. See our docs for details:
* https://wasp-lang.dev/docs/language/features#the-useaction-hook.
*/
export type QuerySpecifier<Input, Output> = [Query<Input, Output>, ...any[]]
/**
* A hook for adding extra behavior to a Wasp Action (e.g., optimistic updates).
*
* @param actionFn The Wasp Action you wish to enhance/decorate.
* @param actionOptions An options object for enhancing/decorating the given Action.
* @returns A decorated Action with added behavior but an unchanged API.
*/
export function useAction<Input = unknown, Output = unknown>(
actionFn: Action<Input, Output>,
actionOptions?: ActionOptions<Input>
): typeof actionFn {
const queryClient = useQueryClient();
let mutationFn = actionFn
let options = {}
if (actionOptions?.optimisticUpdates) {
const optimisticUpdatesDefinitions = actionOptions.optimisticUpdates.map(translateToInternalDefinition)
mutationFn = makeOptimisticUpdateMutationFn(actionFn, optimisticUpdatesDefinitions)
options = makeRqOptimisticUpdateOptions(queryClient, optimisticUpdatesDefinitions)
}
// NOTE: We decided to hide React Query's extra mutation features (e.g.,
// isLoading, onSuccess and onError callbacks, synchronous mutate) and only
// expose a simple async function whose API matches the original Action.
// We did this to avoid cluttering the API with stuff we're not sure we need
// yet (e.g., isLoading), to postpone the action vs mutation dilemma, and to
// clearly separate our opinionated API from React Query's lower-level
// advanced API (which users can also use)
const mutation = useMutation(mutationFn, options)
return (args) => mutation.mutateAsync(args)
}
/**
* An internal (undocumented, private, desugared) way of defining optimistic updates.
*/
type InternalOptimisticUpdateDefinition<ActionInput, CachedData> = {
getQueryKey: (item: ActionInput) => QueryKey,
updateQuery: UpdateQuery<ActionInput, CachedData>;
}
/**
* An UpdateQuery function "instantiated" with a specific item. It only takes
* the current state of the cache and returns the desired (new) state of the
* cache.
*/
type SpecificUpdateQuery<CachedData> = (oldData: CachedData) => CachedData
/**
* A specific, "instantiated" optimistic update definition which contains a
* fully-constructed query key and a specific update function.
*/
type SpecificOptimisticUpdateDefinition<Item> = {
queryKey: QueryKey;
updateQuery: SpecificUpdateQuery<Item>;
}
type InternalAction<Input, Output> = Action<Input, Output> & {
internal<CachedData extends unknown>(
item: Input,
optimisticUpdateDefinitions: SpecificOptimisticUpdateDefinition<CachedData>[]
): Promise<Output>
}
/**
* Translates/Desugars a public optimistic update definition object into a
* definition object our system uses internally.
*
* @param publicOptimisticUpdateDefinition An optimistic update definition
* object that's a part of the public API:
* https://wasp-lang.dev/docs/language/features#the-useaction-hook.
* @returns An internally-used optimistic update definition object.
*/
function translateToInternalDefinition<Item, CachedData>(
publicOptimisticUpdateDefinition: OptimisticUpdateDefinition<Item, CachedData>
): InternalOptimisticUpdateDefinition<Item, CachedData> {
const { getQuerySpecifier, updateQuery } = publicOptimisticUpdateDefinition
const definitionErrors = []
if (typeof getQuerySpecifier !== 'function') {
definitionErrors.push('`getQuerySpecifier` is not a function.')
}
if (typeof updateQuery !== 'function') {
definitionErrors.push('`updateQuery` is not a function.')
}
if (definitionErrors.length) {
throw new TypeError(`Invalid optimistic update definition: ${definitionErrors.join(', ')}.`)
}
return {
getQueryKey: (item) => getRqQueryKeyFromSpecifier(getQuerySpecifier(item)),
updateQuery,
}
}
/**
* Creates a function that performs an action while telling it about the
* optimistic updates it caused.
*
* @param actionFn The Wasp Action.
* @param optimisticUpdateDefinitions The optimisitc updates the action causes.
* @returns An decorated action which performs optimistic updates.
*/
function makeOptimisticUpdateMutationFn<Input, Output, CachedData>(
actionFn: Action<Input, Output>,
optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition<Input, CachedData>[]
): typeof actionFn {
return function performActionWithOptimisticUpdates(item) {
const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map(
generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item)
)
return (actionFn as InternalAction<Input, Output>).internal(item, specificOptimisticUpdateDefinitions)
}
}
/**
* Given a ReactQuery query client and our internal definition of optimistic
* updates, this function constructs an object describing those same optimistic
* updates in a format we can pass into React Query's useMutation hook. In other
* words, it translates our optimistic updates definition into React Query's
* optimistic updates definition. Check their docs for details:
* https://tanstack.com/query/v4/docs/guides/optimistic-updates?from=reactQueryV3&original=https://react-query-v3.tanstack.com/guides/optimistic-updates
*
* @param queryClient The QueryClient instance used by React Query.
* @param optimisticUpdateDefinitions A list containing internal optimistic
* updates definition objects (i.e., a list where each object carries the
* instructions for performing particular optimistic update).
* @returns An object containing 'onMutate' and 'onError' functions
* corresponding to the given optimistic update definitions (check the docs
* linked above for details).
*/
function makeRqOptimisticUpdateOptions<ActionInput, CachedData>(
queryClient: QueryClient,
optimisticUpdateDefinitions: InternalOptimisticUpdateDefinition<ActionInput, CachedData>[]
): Pick<UseMutationOptions, "onMutate" | "onError"> {
async function onMutate(item) {
const specificOptimisticUpdateDefinitions = optimisticUpdateDefinitions.map(
generalDefinition => getOptimisticUpdateDefinitionForSpecificItem(generalDefinition, item)
)
// Cancel any outgoing refetches (so they don't overwrite our optimistic update).
// Theoretically, we can be a bit faster. Instead of awaiting the
// cancellation of all queries, we could cancel and update them in parallel.
// However, awaiting cancellation hasn't yet proven to be a performance bottleneck.
await Promise.all(specificOptimisticUpdateDefinitions.map(
({ queryKey }) => queryClient.cancelQueries(queryKey)
))
// We're using a Map to correctly serialize query keys that contain objects.
const previousData = new Map()
specificOptimisticUpdateDefinitions.forEach(({ queryKey, updateQuery }) => {
// Snapshot the currently cached value.
const previousDataForQuery: CachedData = queryClient.getQueryData(queryKey)
// Attempt to optimistically update the cache using the new value.
try {
queryClient.setQueryData(queryKey, updateQuery)
} catch (e) {
console.error("The `updateQuery` function threw an exception, skipping optimistic update:")
console.error(e)
}
// Remember the snapshotted value to restore in case of an error.
previousData.set(queryKey, previousDataForQuery)
})
return { previousData }
}
function onError(_err, _item, context) {
// All we do in case of an error is roll back all optimistic updates. We ensure
// not to do anything else because React Query rethrows the error. This allows
// the programmer to handle the error as they usually would (i.e., we want the
// error handling to work as it would if the programmer wasn't using optimistic
// updates).
context.previousData.forEach(async (data, queryKey) => {
await queryClient.cancelQueries(queryKey)
queryClient.setQueryData(queryKey, data)
})
}
return {
onMutate,
onError,
}
}
/**
* Constructs the definition for optimistically updating a specific item. It
* uses a closure over the updated item to construct an item-specific query key
* (e.g., useful when the query key depends on an ID).
*
* @param optimisticUpdateDefinition The general, "uninstantiated" optimistic
* update definition with a function for constructing the query key.
* @param item The item triggering the Action/optimistic update (i.e., the
* argument passed to the Action).
* @returns A specific optimistic update definition which corresponds to the
* provided definition and closes over the provided item.
*/
function getOptimisticUpdateDefinitionForSpecificItem<ActionInput, CachedData>(
optimisticUpdateDefinition: InternalOptimisticUpdateDefinition<ActionInput, CachedData>,
item: ActionInput
): SpecificOptimisticUpdateDefinition<CachedData> {
const { getQueryKey, updateQuery } = optimisticUpdateDefinition
return {
queryKey: getQueryKey(item),
updateQuery: (old) => updateQuery(item, old)
}
}
/**
* Translates a Wasp query specifier to a query cache key used by React Query.
*
* @param querySpecifier A query specifier that's a part of the public API:
* https://wasp-lang.dev/docs/language/features#the-useaction-hook.
* @returns A cache key React Query internally uses for addressing queries.
*/
function getRqQueryKeyFromSpecifier(querySpecifier: QuerySpecifier<unknown, unknown>): QueryKey {
const [queryFn, ...otherKeys] = querySpecifier
return [...(queryFn as any).queryCacheKey, ...otherKeys]
}

View File

@ -0,0 +1,74 @@
import axios from 'axios'
import config from './config'
const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = "authToken"
let authToken = null
if (window.localStorage) {
authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME)
}
export const setAuthToken = (token) => {
if (typeof token !== 'string') {
throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`)
}
authToken = token
window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token)
}
export const clearAuthToken = () => {
authToken = undefined
window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME)
}
export const clearLocalStorage = () => {
authToken = undefined
window.localStorage && window.localStorage.clear()
}
api.interceptors.request.use(request => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
}
return request
})
api.interceptors.response.use(undefined, error => {
if (error.response?.status === 401) {
clearAuthToken()
}
return Promise.reject(error)
})
/**
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
* standard format to be further used by the client. It is also assumed that given API
* error has been formatted as implemented by HttpError on the server.
*/
export const handleApiError = (error) => {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
// If error had JSON response, we assume it is of format { message, data } and
// add that info to the error.
// TODO: We might want to use HttpError here instead of just Error, since
// HttpError is also used on server to throw errors like these.
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
const e = new Error(responseJson?.message || error.message)
e.statusCode = responseStatusCode
e.data = responseJson?.data
throw e
} else {
// If any other error, we just propagate it.
throw error
}
}
export default api

View File

@ -0,0 +1,46 @@
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'
import login from '../login.js'
import { errorMessage } from '../../utils.js'
const LoginForm = () => {
const history = useHistory()
const [usernameFieldVal, setUsernameFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleLogin = async (event) => {
event.preventDefault()
try {
await login(usernameFieldVal, passwordFieldVal)
// Redirect to configured page, defaults to /.
history.push('/')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
}
}
return (
<form onSubmit={handleLogin} className="login-form auth-form">
<h2>Username</h2>
<input
type="text"
value={usernameFieldVal}
onChange={e => setUsernameFieldVal(e.target.value)}
/>
<h2>Password</h2>
<input
type="password"
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
/>
<div>
<input type="submit" value="Log in"/>
</div>
</form>
)
}
export default LoginForm

View File

@ -0,0 +1,52 @@
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'
import signup from '../signup.js'
import login from '../login.js'
import { errorMessage } from '../../utils.js'
const SignupForm = () => {
const history = useHistory()
const [usernameFieldVal, setUsernameFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleSignup = async (event) => {
event.preventDefault()
try {
await signup({ username: usernameFieldVal, password: passwordFieldVal })
await login (usernameFieldVal, passwordFieldVal)
setUsernameFieldVal('')
setPasswordFieldVal('')
// Redirect to configured page, defaults to /.
history.push('/')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
}
}
return (
<form onSubmit={handleSignup} className='signup-form auth-form'>
<h2>Username</h2>
<input
type="text"
value={usernameFieldVal}
onChange={e => setUsernameFieldVal(e.target.value)}
/>
<h2>Password</h2>
<input
type="password"
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
/>
<div>
<input type="submit" value="Sign up"/>
</div>
</form>
)
}
export default SignupForm

View File

@ -0,0 +1,44 @@
import config from '../../config.js'
export const signInUrl = `${config.apiUrl}/auth/external/google/login`
export const logoUrl = '/images/google-logo-icon.png'
const containerStyle = {
border: '2px solid #cbd5e1',
margin: 0,
cursor: 'pointer',
borderRadius: '.375rem',
backgroundColor: '#f8fafc',
fontWeight: 600,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
outline: '2px solid transparent',
outlineOffset: '2px',
}
const linkStyle = {
display: 'flex',
alignItems: 'center',
textDecoration: 'none',
color: '#1e293b',
paddingLeft: '1.5rem',
paddingRight: '1.5rem',
paddingTop: '.75rem',
paddingBottom: '.75rem',
}
const logoStyle = {
maxHeight: '24px',
marginRight: '0.75rem'
}
export function SignInButton() {
return (
<div style={containerStyle}>
<a href={signInUrl} style={linkStyle}>
<img alt="Google Icon" src={logoUrl} style={logoStyle} />
<span>Log in with Google</span>
</a>
</div>
)
}

View File

@ -0,0 +1,14 @@
import { setAuthToken } from '../../api.js'
import { invalidateAndRemoveQueries } from '../../operations/resources'
export async function initSession(token: string): Promise<void> {
setAuthToken(token)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.
// TODO(filip): We are currently removing all the queries, but we should
// remove only non-public, user-dependent queries - public queries are
// expected not to change in respect to the currently logged in user.
await invalidateAndRemoveQueries()
}

View File

@ -0,0 +1,13 @@
import api, { handleApiError } from '../api.js'
import { initSession } from './helpers/user'
export default async function login(username, password) {
try {
const args = { username, password }
const response = await api.post('/auth/login', args)
await initSession(response.data.token)
} catch (error) {
handleApiError(error)
}
}

View File

@ -0,0 +1,9 @@
import { clearLocalStorage } from '../api.js'
import { invalidateAndRemoveQueries } from '../operations/resources'
export default async function logout() {
clearLocalStorage()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
}

View File

@ -0,0 +1,56 @@
import React, { useEffect } from 'react'
import { useHistory } from 'react-router-dom'
import config from '../../config.js'
import api from '../../api.js'
import { initSession } from '../helpers/user'
// After a user authenticates via an Oauth 2.0 provider, this is the page that
// the provider should redirect them to, while providing query string parameters
// that contain information needed for the API server to authenticate with the provider.
// This page forwards that information to the API server and in response get a JWT,
// which it stores on the client, therefore completing the OAuth authentication process.
export default function OAuthCodeExchange({ pathToApiServerRouteHandlingOauthRedirect }) {
const history = useHistory()
useEffect(() => {
// NOTE: Different auth methods will have different Wasp API server validation paths.
// This helps us reuse one component for various methods (e.g., Google, Facebook, etc.).
const apiServerUrlHandlingOauthRedirect = constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect)
exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect)
}, [history, pathToApiServerRouteHandlingOauthRedirect])
return (
<p>Completing login process...</p>
)
}
function constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect) {
// Take the redirect query params supplied by the external OAuth provider and
// send them as-is to our backend, so Passport can finish the process.
const queryParams = window.location.search
return `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}`
}
async function exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) {
const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect)
if (token !== null) {
await initSession(token)
history.push('/')
} else {
console.error('Error obtaining JWT token')
history.push('/login')
}
}
async function exchangeCodeForJwt(url) {
try {
const response = await api.get(url)
return response?.data?.token || null
} catch (e) {
console.error(e)
return null
}
}

View File

@ -0,0 +1,30 @@
import React from 'react'
import { Redirect } from 'react-router-dom'
import useAuth from '../useAuth.js'
const createAuthRequiredPage = (Page) => {
return (props) => {
const { data: user, isError, isSuccess, isLoading } = useAuth()
if (isSuccess) {
if (user) {
return (
<Page {...props} user={user} />
)
} else {
return <Redirect to="/login" />
}
} else if (isLoading) {
return <span>Loading...</span>
} else if (isError) {
return <span>An error ocurred. Please refresh the page.</span>
} else {
return <span>An unknown error ocurred. Please refresh the page.</span>
}
}
}
export default createAuthRequiredPage

View File

@ -0,0 +1,9 @@
import api, { handleApiError } from '../api.js'
export default async function signup(userFields) {
try {
await api.post('/auth/signup', userFields)
} catch (error) {
handleApiError(error)
}
}

View File

@ -0,0 +1,21 @@
import { useQuery } from '../queries'
import api, { handleApiError } from '../api.js'
export default function useAuth(queryFnArgs, config) {
return useQuery(getMe, queryFnArgs, config)
}
async function getMe() {
try {
const response = await api.get('/auth/me')
return response.data
} catch (error) {
if (error.response?.status === 401) {
return null
} else {
handleApiError(error)
}
}
}
getMe.queryCacheKey = ['auth/me']

View File

@ -0,0 +1,9 @@
import { stripTrailingSlash } from "./universal/url";
const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || 'http://localhost:3001';
const config = {
apiUrl,
}
export default config

View File

@ -0,0 +1,14 @@
import {
User,
SocialLogin,
} from '@prisma/client'
export type {
User,
SocialLogin,
} from '@prisma/client'
export type Entity =
| User
| SocialLogin
| never

View File

@ -0,0 +1,9 @@
export default function App({ children }) {
return (
<div className="app">
<h1>Root component</h1>
{children}
</div>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,40 @@
import waspLogo from './waspLogo.png'
import './Main.css'
const MainPage = () => {
return (
<div className="container">
<main>
<div className="logo">
<img src={waspLogo} alt="wasp" />
</div>
<h2 className="welcome-title"> Welcome to Wasp - you just started a new app! </h2>
<h3 className="welcome-subtitle">
This is page <code>MainPage</code> located at route <code>/</code>.
Open <code>src/client/MainPage.jsx</code> to edit it.
</h3>
<div className="buttons">
<a
className="button button-filled"
href="https://wasp-lang.dev/docs/tutorials/todo-app"
target="_blank"
rel="noreferrer noopener"
>
Take the Tutorial
</a>
<a
className="button button-outline"
href="https://discord.com/invite/rzdnErX"
target="_blank"
rel="noreferrer noopener"
>
Chat on Discord
</a>
</div>
</main>
</div>
)
}
export default MainPage

View File

@ -0,0 +1,4 @@
export default function myClientSetupFunction() {
// Do some client setup here.
}

View File

@ -0,0 +1 @@
/// <reference types="../../.wasp/out/web-app/node_modules/vite/client" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,30 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { QueryClientProvider } from '@tanstack/react-query'
import router from './router'
import {
initializeQueryClient,
queryClientInitialized,
} from './queryClient'
import myClientSetupFunction from './ext-src/myClientSetupCode.js'
startApp()
async function startApp() {
await myClientSetupFunction()
initializeQueryClient()
await render()
}
async function render() {
const queryClient = await queryClientInitialized
ReactDOM.render(
<QueryClientProvider client={queryClient}>
{router}
</QueryClientProvider>,
document.getElementById('root')
)
}

Some files were not shown because too many files have changed in this diff Show More