mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-09-11 15:26:19 +03:00
Create restructuring prototype (#1584)
This commit is contained in:
parent
b227daf5e3
commit
6f0714ad23
2
examples/streaming/src/client/vite-env.d.ts
vendored
2
examples/streaming/src/client/vite-env.d.ts
vendored
@ -1 +1 @@
|
||||
/// <reference types="../../.wasp/out/web-app/node_modules/vite/client" />
|
||||
/// <reference types="vite/client" />
|
||||
|
@ -1,13 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
@ -44,13 +44,13 @@ dotWaspInfoFileInGeneratedCodeDir :: Path' (Rel Wasp.Generator.Common.ProjectRoo
|
||||
dotWaspInfoFileInGeneratedCodeDir = [relfile|.waspinfo|]
|
||||
|
||||
extServerCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir)
|
||||
extServerCodeDirInWaspProjectDir = [reldir|src/server|]
|
||||
extServerCodeDirInWaspProjectDir = [reldir|src|]
|
||||
|
||||
extClientCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir)
|
||||
extClientCodeDirInWaspProjectDir = [reldir|src/client|]
|
||||
extClientCodeDirInWaspProjectDir = [reldir|src|]
|
||||
|
||||
extSharedCodeDirInWaspProjectDir :: Path' (Rel WaspProjectDir) (Dir SourceExternalCodeDir)
|
||||
extSharedCodeDirInWaspProjectDir = [reldir|src/shared|]
|
||||
extSharedCodeDirInWaspProjectDir = [reldir|src|]
|
||||
|
||||
waspSays :: String -> IO ()
|
||||
waspSays what = putStrLn $ Term.applyStyles [Term.Yellow] what
|
||||
|
10
waspc/data/Cli/templates/basic/package.json
Normal file
10
waspc/data/Cli/templates/basic/package.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "prototype",
|
||||
"dependencies": {
|
||||
"wasp": "file:.wasp/out/sdk/wasp",
|
||||
"react": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.37"
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ import router from './router'
|
||||
import {
|
||||
initializeQueryClient,
|
||||
queryClientInitialized,
|
||||
} from './queryClient'
|
||||
} from 'wasp/rpc/queryClient'
|
||||
|
||||
{=# setupFn.isDefined =}
|
||||
{=& setupFn.importStatement =}
|
||||
|
@ -12,7 +12,7 @@ import type {
|
||||
{=/ rootComponent.isDefined =}
|
||||
|
||||
{=# isAuthEnabled =}
|
||||
import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
|
||||
import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage"
|
||||
{=/ isAuthEnabled =}
|
||||
|
||||
{=# pagesToImport =}
|
||||
|
@ -8,6 +8,12 @@
|
||||
// Allow importing pages with the .tsx extension.
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
@ -14,6 +14,9 @@ const _waspUserProvidedConfig = {};
|
||||
const defaultViteConfig = {
|
||||
base: "{= baseDir =}",
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['wasp']
|
||||
},
|
||||
server: {
|
||||
port: {= defaultClientPort =},
|
||||
host: "0.0.0.0",
|
||||
|
127
waspc/data/Generator/templates/sdk/dependencies.txt
Normal file
127
waspc/data/Generator/templates/sdk/dependencies.txt
Normal file
@ -0,0 +1,127 @@
|
||||
Dependencies:
|
||||
|
||||
("@prisma/client", show prismaVersion), // sdk
|
||||
("@tanstack/react-query", "^4.29.0"), // sdk
|
||||
("axios", "^1.4.0"), // sdk
|
||||
("cookie-parser", "~1.4.6"), //
|
||||
("cors", "^2.8.5"), //
|
||||
("dotenv", "16.0.2"), //
|
||||
("express", "~4.18.1"), // sdk (for types)
|
||||
("helmet", "^6.0.0"), //
|
||||
("jsonwebtoken", "^8.5.1"), // sdk
|
||||
("lodash.merge", "^4.6.2"), //
|
||||
("mitt", "3.0.0"), // sdk
|
||||
("morgan", "~1.10.0"), //
|
||||
("patch-package", "^6.4.7"), //
|
||||
("rate-limiter-flexible", "^2.4.1"), //
|
||||
("react", "^18.2.0"), // sdk
|
||||
("react-dom", "^18.2.0"), //
|
||||
("react-hook-form", "^7.45.4") //
|
||||
("react-router-dom", "^5.3.3"), // sdk
|
||||
("secure-password", "^4.0.0"), // sdk
|
||||
("superjson", "^1.12.2"), // sdk
|
||||
("uuid", "^9.0.0"), //
|
||||
|
||||
Dev dependencies:
|
||||
("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"),
|
||||
("@tsconfig/vite-react", "^2.0.0")
|
||||
("@types/cors", "^2.8.5")
|
||||
("@types/express", "^4.17.13"),
|
||||
("@types/express-serve-static-core", "^4.17.13"),
|
||||
("@types/node", "^18.11.9"),
|
||||
("@types/react", "^18.0.37"),
|
||||
("@types/react-dom", "^18.0.11"),
|
||||
("@types/react-router-dom", "^5.3.3"),
|
||||
("@types/uuid", "^9.0.0"),
|
||||
("@vitejs/plugin-react-swc", "^3.0.0"),
|
||||
("dotenv", "^16.0.3"), // duplicate
|
||||
("nodemon", "^2.0.19"), //
|
||||
("prisma", show prismaVersion), //
|
||||
("standard", "^17.0.0"), //
|
||||
("typescript", "^5.1.0"), //
|
||||
("vite", "^4.3.9"), //
|
||||
|
||||
Their package.json:
|
||||
("react", "^18.2.0"),
|
||||
("typescript", "^5.1.0")
|
||||
|
||||
|
||||
Server
|
||||
|
||||
("cookie-parser", "~1.4.6"),
|
||||
- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts
|
||||
|
||||
("cors", "^2.8.5"),
|
||||
- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts
|
||||
|
||||
("express", "~4.18.1"),
|
||||
- Generator/templates/server/src/auth/providers/config/local.ts
|
||||
- Generator/templates/server/src/auth/providers/config/email.ts
|
||||
- Generator/templates/server/src/routes/crud/index.ts
|
||||
- Generator/templates/server/src/routes/crud/_crud.ts
|
||||
- Generator/templates/server/src/routes/operations/index.js
|
||||
- Generator/templates/server/src/routes/index.js
|
||||
- Generator/templates/server/src/auth/providers/index.ts
|
||||
- Generator/templates/server/src/auth/providers/oauth/createRouter.ts
|
||||
- Generator/templates/server/src/routes/apis/index.ts
|
||||
- Generator/templates/server/src/auth/providers/types.ts
|
||||
- Generator/templates/server/src/types/index.ts
|
||||
- Generator/templates/server/src/middleware/globalMiddleware.ts
|
||||
- Generator/templates/server/src/app.js
|
||||
- Generator/templates/server/src/auth/providers/email/signup.ts
|
||||
- Generator/templates/server/src/routes/auth/index.js
|
||||
- Generator/templates/server/src/auth/providers/email/login.ts
|
||||
- Generator/templates/server/src/auth/providers/email/resetPassword.ts
|
||||
- Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts
|
||||
- Generator/templates/server/src/auth/providers/email/verifyEmail.ts
|
||||
- Generator/templates/server/src/_types/index.ts
|
||||
- Generator/templates/server/src/apis/types.ts
|
||||
|
||||
("morgan", "~1.10.0"),
|
||||
- [Framework] Generator/templates/server/src/middleware/globalMiddleware.ts
|
||||
|
||||
("@prisma/client", show prismaVersion),
|
||||
- [SDK] Generator/templates/react-app/src/entities/index.ts
|
||||
- [SDK] Generator/templates/server/src/dbClient.ts
|
||||
- [Framework] Generator/templates/server/src/utils.js
|
||||
- Generator/templates/server/src/auth/utils.ts
|
||||
- Generator/templates/server/src/entities/index.ts
|
||||
- Generator/templates/server/src/auth/providers/oauth/types.ts
|
||||
- Generator/templates/server/src/crud/_operations.ts
|
||||
- Generator/templates/server/src/dbSeed/types.ts
|
||||
|
||||
|
||||
("jsonwebtoken", "^8.5.1"),
|
||||
-- NOTE: secure-password has a package.json override for sodium-native.
|
||||
("secure-password", "^4.0.0"),
|
||||
("dotenv", "16.0.2"),
|
||||
("helmet", "^6.0.0"),
|
||||
("patch-package", "^6.4.7"),
|
||||
("uuid", "^9.0.0"),
|
||||
("lodash.merge", "^4.6.2"),
|
||||
("rate-limiter-flexible", "^2.4.1"),
|
||||
("superjson", "^1.12.2")
|
||||
|
||||
depsRequiredByPassport spec
|
||||
|
||||
depsRequiredByJobs spec
|
||||
|
||||
depsRequiredByEmail spec
|
||||
|
||||
depsRequiredByWebSockets spec,
|
||||
N.waspDevDependencies =
|
||||
AS.Dependency.fromList
|
||||
[ ("nodemon", "^2.0.19"),
|
||||
("standard", "^17.0.0"),
|
||||
("prisma", show prismaVersion),
|
||||
-- TODO: Allow users to choose whether they want to use TypeScript
|
||||
-- in their projects and install these dependencies accordingly.
|
||||
("typescript", "^5.1.0"),
|
||||
("@types/express", "^4.17.13"),
|
||||
("@types/express-serve-static-core", "^4.17.13"),
|
||||
("@types/node", "^18.11.9"),
|
||||
("@tsconfig/node" ++ show (major NodeVersion.latestMajorNodeVersion), "^1.0.1"),
|
||||
("@types/uuid", "^9.0.0"),
|
||||
("@types/cors", "^2.8.5")
|
||||
]
|
||||
}
|
36
waspc/data/Generator/templates/sdk/package.json
Normal file
36
waspc/data/Generator/templates/sdk/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{{={= =}=}}
|
||||
{
|
||||
"name": "wasp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"types": "tsc --declaration --emitDeclarationOnly --stripInternal --declarationDir dist"
|
||||
},
|
||||
"exports": {
|
||||
"./core/HttpError": "./core/HttpError.js",
|
||||
"./core/AuthError": "./core/AuthError.js",
|
||||
"./core/config": "./core/config.js",
|
||||
"./core/stitches.config": "./core/stitches.config.js",
|
||||
"./core/storage": "./core/storage.ts",
|
||||
"./rpc": "./rpc/index.ts",
|
||||
"./rpc/queries": "./rpc/queries/index.ts",
|
||||
"./rpc/actions": "./rpc/actions/index.ts",
|
||||
"./rpc/queryClient": "./rpc/queryClient.ts",
|
||||
"./types": "./types/index.ts",
|
||||
"./auth/*": "./auth/*",
|
||||
"./api": "./api/index.ts",
|
||||
"./api/*": "./api/*",
|
||||
"./operations": "./operations/index.ts",
|
||||
"./operations/*": "./operations/*",
|
||||
"./universal/url": "./universal/url.ts",
|
||||
"./universal/types": "./universal/url.ts"
|
||||
},
|
||||
"license": "ISC",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
{=& depsChunk =},
|
||||
{=& devDepsChunk =}
|
||||
}
|
11
waspc/data/Generator/templates/sdk/wasp/api/events.ts
Normal file
11
waspc/data/Generator/templates/sdk/wasp/api/events.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import mitt, { Emitter } from 'mitt';
|
||||
|
||||
type ApiEvents = {
|
||||
// key: Event name
|
||||
// type: Event payload type
|
||||
'sessionId.set': void;
|
||||
'sessionId.clear': void;
|
||||
};
|
||||
|
||||
// Used to allow API clients to register for auth session ID change events.
|
||||
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();
|
104
waspc/data/Generator/templates/sdk/wasp/api/index.ts
Normal file
104
waspc/data/Generator/templates/sdk/wasp/api/index.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
|
||||
import config from 'wasp/core/config'
|
||||
import { storage } from 'wasp/core/storage'
|
||||
import { apiEventsEmitter } from 'wasp/api/events'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: config.apiUrl,
|
||||
})
|
||||
|
||||
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
|
||||
|
||||
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
|
||||
|
||||
export function setSessionId(sessionId: string): void {
|
||||
waspAppAuthSessionId = sessionId
|
||||
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
|
||||
apiEventsEmitter.emit('sessionId.set')
|
||||
}
|
||||
|
||||
export function getSessionId(): string | undefined {
|
||||
return waspAppAuthSessionId
|
||||
}
|
||||
|
||||
export function clearSessionId(): void {
|
||||
waspAppAuthSessionId = undefined
|
||||
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
|
||||
apiEventsEmitter.emit('sessionId.clear')
|
||||
}
|
||||
|
||||
export function removeLocalUserData(): void {
|
||||
waspAppAuthSessionId = undefined
|
||||
storage.clear()
|
||||
apiEventsEmitter.emit('sessionId.clear')
|
||||
}
|
||||
|
||||
api.interceptors.request.use((request) => {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId) {
|
||||
request.headers['Authorization'] = `Bearer ${sessionId}`
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
api.interceptors.response.use(undefined, (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
clearSessionId()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
})
|
||||
|
||||
// This handler will run on other tabs (not the active one calling API functions),
|
||||
// and will ensure they know about auth session ID changes.
|
||||
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
|
||||
// "Note: This won't work on the same page that is making the changes — it is really a way
|
||||
// for other pages on the domain using the storage to sync any changes that are made."
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
|
||||
if (!!event.newValue) {
|
||||
waspAppAuthSessionId = event.newValue
|
||||
apiEventsEmitter.emit('sessionId.set')
|
||||
} else {
|
||||
waspAppAuthSessionId = undefined
|
||||
apiEventsEmitter.emit('sessionId.clear')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Takes an error returned by the app's API (as returned by axios), and transforms into a more
|
||||
* standard format to be further used by the client. It is also assumed that given API
|
||||
* error has been formatted as implemented by HttpError on the server.
|
||||
*/
|
||||
export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
|
||||
if (error?.response) {
|
||||
// If error came from HTTP response, we capture most informative message
|
||||
// and also add .statusCode information to it.
|
||||
// If error had JSON response, we assume it is of format { message, data } and
|
||||
// add that info to the error.
|
||||
// TODO: We might want to use HttpError here instead of just Error, since
|
||||
// HttpError is also used on server to throw errors like these.
|
||||
// That would require copying HttpError code to web-app also and using it here.
|
||||
const responseJson = error.response?.data
|
||||
const responseStatusCode = error.response.status
|
||||
throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
|
||||
} else {
|
||||
// If any other error, we just propagate it.
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
class WaspHttpError extends Error {
|
||||
statusCode: number
|
||||
|
||||
data: unknown
|
||||
|
||||
constructor (statusCode: number, message: string, data: unknown) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
85
waspc/data/Generator/templates/sdk/wasp/auth/forms/Auth.tsx
Normal file
85
waspc/data/Generator/templates/sdk/wasp/auth/forms/Auth.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useState, createContext } from 'react'
|
||||
import { createTheme } from '@stitches/react'
|
||||
import { styled } from 'wasp/core/stitches.config'
|
||||
|
||||
import {
|
||||
type State,
|
||||
type CustomizationOptions,
|
||||
type ErrorMessage,
|
||||
type AdditionalSignupFields,
|
||||
} from './types'
|
||||
import { LoginSignupForm } from './internal/common/LoginSignupForm'
|
||||
import { MessageError, MessageSuccess } from './internal/Message'
|
||||
|
||||
const logoStyle = {
|
||||
height: '3rem'
|
||||
}
|
||||
|
||||
const Container = styled('div', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})
|
||||
|
||||
const HeaderText = styled('h2', {
|
||||
fontSize: '1.875rem',
|
||||
fontWeight: '700',
|
||||
marginTop: '1.5rem'
|
||||
})
|
||||
|
||||
|
||||
export const AuthContext = createContext({
|
||||
isLoading: false,
|
||||
setIsLoading: (isLoading: boolean) => {},
|
||||
setErrorMessage: (errorMessage: ErrorMessage | null) => {},
|
||||
setSuccessMessage: (successMessage: string | null) => {},
|
||||
})
|
||||
|
||||
function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: {
|
||||
state: State;
|
||||
} & CustomizationOptions & {
|
||||
additionalSignupFields?: AdditionalSignupFields;
|
||||
}) {
|
||||
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// TODO(matija): this is called on every render, is it a problem?
|
||||
// If we do it in useEffect(), then there is a glitch between the default color and the
|
||||
// user provided one.
|
||||
const customTheme = createTheme(appearance ?? {})
|
||||
|
||||
const titles: Record<State, string> = {
|
||||
login: 'Log in to your account',
|
||||
signup: 'Create a new account',
|
||||
}
|
||||
const title = titles[state]
|
||||
|
||||
const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal'
|
||||
|
||||
return (
|
||||
<Container className={customTheme}>
|
||||
<div>
|
||||
{logo && (<img style={logoStyle} src={logo} alt='Your Company' />)}
|
||||
<HeaderText>{title}</HeaderText>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<MessageError>
|
||||
{errorMessage.title}{errorMessage.description && ': '}{errorMessage.description}
|
||||
</MessageError>
|
||||
)}
|
||||
{successMessage && <MessageSuccess>{successMessage}</MessageSuccess>}
|
||||
<AuthContext.Provider value={{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }}>
|
||||
{(state === 'login' || state === 'signup') && (
|
||||
<LoginSignupForm
|
||||
state={state}
|
||||
socialButtonsDirection={socialButtonsDirection}
|
||||
additionalSignupFields={additionalSignupFields}
|
||||
/>
|
||||
)}
|
||||
</AuthContext.Provider>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Auth;
|
17
waspc/data/Generator/templates/sdk/wasp/auth/forms/Login.tsx
Normal file
17
waspc/data/Generator/templates/sdk/wasp/auth/forms/Login.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import Auth from './Auth'
|
||||
import { type CustomizationOptions, State } from './types'
|
||||
|
||||
export function LoginForm({
|
||||
appearance,
|
||||
logo,
|
||||
socialLayout,
|
||||
}: CustomizationOptions) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.Login}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import Auth from './Auth'
|
||||
import {
|
||||
type CustomizationOptions,
|
||||
type AdditionalSignupFields,
|
||||
State,
|
||||
} from './types'
|
||||
|
||||
export function SignupForm({
|
||||
appearance,
|
||||
logo,
|
||||
socialLayout,
|
||||
additionalFields,
|
||||
}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.Signup}
|
||||
additionalSignupFields={additionalFields}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
import { styled } from 'wasp/core/stitches.config'
|
||||
|
||||
export const Form = styled('form', {
|
||||
marginTop: '1.5rem',
|
||||
})
|
||||
|
||||
export const FormItemGroup = styled('div', {
|
||||
'& + div': {
|
||||
marginTop: '1.5rem',
|
||||
},
|
||||
})
|
||||
|
||||
export const FormLabel = styled('label', {
|
||||
display: 'block',
|
||||
fontSize: '$sm',
|
||||
fontWeight: '500',
|
||||
marginBottom: '0.5rem',
|
||||
})
|
||||
|
||||
const commonInputStyles = {
|
||||
display: 'block',
|
||||
lineHeight: '1.5rem',
|
||||
fontSize: '$sm',
|
||||
borderWidth: '1px',
|
||||
borderColor: '$gray600',
|
||||
backgroundColor: '#f8f4ff',
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
'&:focus': {
|
||||
borderWidth: '1px',
|
||||
borderColor: '$gray700',
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
backgroundColor: '$gray400',
|
||||
borderColor: '$gray400',
|
||||
color: '$gray500',
|
||||
},
|
||||
|
||||
borderRadius: '0.375rem',
|
||||
width: '100%',
|
||||
|
||||
paddingTop: '0.375rem',
|
||||
paddingBottom: '0.375rem',
|
||||
paddingLeft: '0.75rem',
|
||||
paddingRight: '0.75rem',
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
export const FormInput = styled('input', commonInputStyles)
|
||||
|
||||
export const FormTextarea = styled('textarea', commonInputStyles)
|
||||
|
||||
export const FormError = styled('div', {
|
||||
display: 'block',
|
||||
fontSize: '$sm',
|
||||
fontWeight: '500',
|
||||
color: '$formErrorText',
|
||||
marginTop: '0.5rem',
|
||||
})
|
||||
|
||||
export const SubmitButton = styled('button', {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
|
||||
width: '100%',
|
||||
borderWidth: '1px',
|
||||
borderColor: '$brand',
|
||||
backgroundColor: '$brand',
|
||||
color: '$submitButtonText',
|
||||
|
||||
padding: '0.5rem 0.75rem',
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
|
||||
fontWeight: '600',
|
||||
fontSize: '$sm',
|
||||
lineHeight: '1.25rem',
|
||||
borderRadius: '0.375rem',
|
||||
|
||||
// TODO(matija): extract this into separate BaseButton component and then inherit it.
|
||||
'&:hover': {
|
||||
backgroundColor: '$brandAccent',
|
||||
borderColor: '$brandAccent',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
backgroundColor: '$gray400',
|
||||
borderColor: '$gray400',
|
||||
color: '$gray500',
|
||||
},
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transitionDuration: '100ms',
|
||||
})
|
@ -0,0 +1,18 @@
|
||||
import { styled } from 'wasp/core/stitches.config'
|
||||
|
||||
export const Message = styled('div', {
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
marginTop: '1rem',
|
||||
background: '$gray400',
|
||||
})
|
||||
|
||||
export const MessageError = styled(Message, {
|
||||
background: '$errorBackground',
|
||||
color: '$errorText',
|
||||
})
|
||||
|
||||
export const MessageSuccess = styled(Message, {
|
||||
background: '$successBackground',
|
||||
color: '$successText',
|
||||
})
|
@ -0,0 +1,178 @@
|
||||
import { useContext } from 'react'
|
||||
import { useForm, UseFormReturn } from 'react-hook-form'
|
||||
import { styled } from 'wasp/core/stitches.config'
|
||||
import config from 'wasp/core/config'
|
||||
|
||||
import { AuthContext } from '../../Auth'
|
||||
import {
|
||||
Form,
|
||||
FormInput,
|
||||
FormItemGroup,
|
||||
FormLabel,
|
||||
FormError,
|
||||
FormTextarea,
|
||||
SubmitButton,
|
||||
} from '../Form'
|
||||
import type {
|
||||
AdditionalSignupFields,
|
||||
AdditionalSignupField,
|
||||
AdditionalSignupFieldRenderFn,
|
||||
FormState,
|
||||
} from '../../types'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { useUsernameAndPassword } from '../usernameAndPassword/useUsernameAndPassword'
|
||||
|
||||
|
||||
export type LoginSignupFormFields = {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export const LoginSignupForm = ({
|
||||
state,
|
||||
socialButtonsDirection = 'horizontal',
|
||||
additionalSignupFields,
|
||||
}: {
|
||||
state: 'login' | 'signup'
|
||||
socialButtonsDirection?: 'horizontal' | 'vertical'
|
||||
additionalSignupFields?: AdditionalSignupFields
|
||||
}) => {
|
||||
const {
|
||||
isLoading,
|
||||
setErrorMessage,
|
||||
setSuccessMessage,
|
||||
setIsLoading,
|
||||
} = useContext(AuthContext)
|
||||
const isLogin = state === 'login'
|
||||
const cta = isLogin ? 'Log in' : 'Sign up';
|
||||
const history = useHistory();
|
||||
const onErrorHandler = (error) => {
|
||||
setErrorMessage({ title: error.message, description: error.data?.data?.message })
|
||||
};
|
||||
const hookForm = useForm<LoginSignupFormFields>()
|
||||
const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
|
||||
const { handleSubmit } = useUsernameAndPassword({
|
||||
isLogin,
|
||||
onError: onErrorHandler,
|
||||
onSuccess() {
|
||||
history.push('/')
|
||||
},
|
||||
});
|
||||
async function onSubmit (data) {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
await handleSubmit(data);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Form onSubmit={hookFormHandleSubmit(onSubmit)}>
|
||||
<FormItemGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormInput
|
||||
{...register('username', {
|
||||
required: 'Username is required',
|
||||
})}
|
||||
type="text"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||
</FormItemGroup>
|
||||
<FormItemGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormInput
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
type="password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.password && <FormError>{errors.password.message}</FormError>}
|
||||
</FormItemGroup>
|
||||
<AdditionalFormFields
|
||||
hookForm={hookForm}
|
||||
formState={{ isLoading }}
|
||||
additionalSignupFields={additionalSignupFields}
|
||||
/>
|
||||
<FormItemGroup>
|
||||
<SubmitButton type="submit" disabled={isLoading}>{cta}</SubmitButton>
|
||||
</FormItemGroup>
|
||||
</Form>
|
||||
</>)
|
||||
}
|
||||
|
||||
function AdditionalFormFields({
|
||||
hookForm,
|
||||
formState: { isLoading },
|
||||
additionalSignupFields,
|
||||
}: {
|
||||
hookForm: UseFormReturn<LoginSignupFormFields>;
|
||||
formState: FormState;
|
||||
additionalSignupFields: AdditionalSignupFields;
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = hookForm;
|
||||
|
||||
function renderField<ComponentType extends React.JSXElementConstructor<any>>(
|
||||
field: AdditionalSignupField,
|
||||
// Ideally we would use ComponentType here, but it doesn't work with react-hook-form
|
||||
Component: any,
|
||||
props?: React.ComponentProps<ComponentType>
|
||||
) {
|
||||
return (
|
||||
<FormItemGroup key={field.name}>
|
||||
<FormLabel>{field.label}</FormLabel>
|
||||
<Component
|
||||
{...register(field.name, field.validations)}
|
||||
{...props}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors[field.name] && (
|
||||
<FormError>{errors[field.name].message}</FormError>
|
||||
)}
|
||||
</FormItemGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (areAdditionalFieldsRenderFn(additionalSignupFields)) {
|
||||
return additionalSignupFields(hookForm, { isLoading })
|
||||
}
|
||||
|
||||
return (
|
||||
additionalSignupFields &&
|
||||
additionalSignupFields.map((field) => {
|
||||
if (isFieldRenderFn(field)) {
|
||||
return field(hookForm, { isLoading })
|
||||
}
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
return renderField<typeof FormInput>(field, FormInput, {
|
||||
type: 'text',
|
||||
})
|
||||
case 'textarea':
|
||||
return renderField<typeof FormTextarea>(field, FormTextarea)
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported additional signup field type: ${field.type}`
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function isFieldRenderFn(
|
||||
additionalSignupField: AdditionalSignupField | AdditionalSignupFieldRenderFn
|
||||
): additionalSignupField is AdditionalSignupFieldRenderFn {
|
||||
return typeof additionalSignupField === 'function'
|
||||
}
|
||||
|
||||
function areAdditionalFieldsRenderFn(
|
||||
additionalSignupFields: AdditionalSignupFields
|
||||
): additionalSignupFields is AdditionalSignupFieldRenderFn {
|
||||
return typeof additionalSignupFields === 'function'
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import signup from '../../../signup'
|
||||
import login from '../../../login'
|
||||
|
||||
export function useUsernameAndPassword({
|
||||
onError,
|
||||
onSuccess,
|
||||
isLogin,
|
||||
}: {
|
||||
onError: (error: Error) => void
|
||||
onSuccess: () => void
|
||||
isLogin: boolean
|
||||
}) {
|
||||
async function handleSubmit(data) {
|
||||
try {
|
||||
if (!isLogin) {
|
||||
await signup(data)
|
||||
}
|
||||
await login(data.username, data.password)
|
||||
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
onError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
39
waspc/data/Generator/templates/sdk/wasp/auth/forms/types.ts
Normal file
39
waspc/data/Generator/templates/sdk/wasp/auth/forms/types.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { createTheme } from '@stitches/react'
|
||||
import { UseFormReturn, RegisterOptions } from 'react-hook-form'
|
||||
import type { LoginSignupFormFields } from './internal/common/LoginSignupForm'
|
||||
|
||||
export enum State {
|
||||
Login = 'login',
|
||||
Signup = 'signup',
|
||||
}
|
||||
|
||||
export type CustomizationOptions = {
|
||||
logo?: string
|
||||
socialLayout?: 'horizontal' | 'vertical'
|
||||
appearance?: Parameters<typeof createTheme>[0]
|
||||
}
|
||||
|
||||
export type ErrorMessage = {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type FormState = {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type AdditionalSignupFieldRenderFn = (
|
||||
hookForm: UseFormReturn<LoginSignupFormFields>,
|
||||
formState: FormState
|
||||
) => React.ReactNode
|
||||
|
||||
export type AdditionalSignupField = {
|
||||
name: string
|
||||
label: string
|
||||
type: 'input' | 'textarea'
|
||||
validations?: RegisterOptions<LoginSignupFormFields>
|
||||
}
|
||||
|
||||
export type AdditionalSignupFields =
|
||||
| (AdditionalSignupField | AdditionalSignupFieldRenderFn)[]
|
||||
| AdditionalSignupFieldRenderFn
|
14
waspc/data/Generator/templates/sdk/wasp/auth/helpers/user.ts
Normal file
14
waspc/data/Generator/templates/sdk/wasp/auth/helpers/user.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { setSessionId } from 'wasp/api'
|
||||
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
|
||||
|
||||
export async function initSession(sessionId: string): Promise<void> {
|
||||
setSessionId(sessionId)
|
||||
// We need to invalidate queries after login in order to get the correct user
|
||||
// data in the React components (using `useAuth`).
|
||||
// Redirects after login won't work properly without this.
|
||||
|
||||
// TODO(filip): We are currently removing all the queries, but we should
|
||||
// remove only non-public, user-dependent queries - public queries are
|
||||
// expected not to change in respect to the currently logged in user.
|
||||
await invalidateAndRemoveQueries()
|
||||
}
|
12
waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts
Normal file
12
waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import util from 'util'
|
||||
|
||||
import config from 'wasp/core/config'
|
||||
|
||||
const jwtSign = util.promisify(jwt.sign)
|
||||
const jwtVerify = util.promisify(jwt.verify)
|
||||
|
||||
const JWT_SECRET = config.auth.jwtSecret
|
||||
|
||||
export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
|
||||
export const verify = (token) => jwtVerify(token, JWT_SECRET)
|
13
waspc/data/Generator/templates/sdk/wasp/auth/login.ts
Normal file
13
waspc/data/Generator/templates/sdk/wasp/auth/login.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import api, { handleApiError } from 'wasp/api'
|
||||
import { initSession } from './helpers/user'
|
||||
|
||||
export default async function login(username: string, password: string): Promise<void> {
|
||||
try {
|
||||
const args = { username, password }
|
||||
const response = await api.post('/auth/username/login', args)
|
||||
|
||||
await initSession(response.data.sessionId)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
17
waspc/data/Generator/templates/sdk/wasp/auth/logout.ts
Normal file
17
waspc/data/Generator/templates/sdk/wasp/auth/logout.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import api, { removeLocalUserData } from 'wasp/api'
|
||||
import { invalidateAndRemoveQueries } from 'wasp/operations/resources'
|
||||
|
||||
export default async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} finally {
|
||||
// Even if the logout request fails, we still want to remove the local user data
|
||||
// in case the logout failed because of a network error and the user walked away
|
||||
// from the computer.
|
||||
removeLocalUserData()
|
||||
|
||||
// TODO(filip): We are currently invalidating and removing all the queries, but
|
||||
// we should remove only the non-public, user-dependent ones.
|
||||
await invalidateAndRemoveQueries()
|
||||
}
|
||||
}
|
55
waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts
Normal file
55
waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Lucia } from "lucia";
|
||||
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
|
||||
import prisma from '../server/dbClient.js'
|
||||
import config from 'wasp/core/config'
|
||||
import { type User } from "../entities/index.js"
|
||||
|
||||
const prismaAdapter = new PrismaAdapter(
|
||||
// Using `as any` here since Lucia's model types are not compatible with Prisma 4
|
||||
// model types. This is a temporary workaround until we migrate to Prisma 5.
|
||||
// This **works** in runtime, but Typescript complains about it.
|
||||
prisma.session as any,
|
||||
prisma.auth as any
|
||||
);
|
||||
|
||||
/**
|
||||
* We are using Lucia for session management.
|
||||
*
|
||||
* Some details:
|
||||
* 1. We are using the Prisma adapter for Lucia.
|
||||
* 2. We are not using cookies for session management. Instead, we are using
|
||||
* the Authorization header to send the session token.
|
||||
* 3. Our `Session` entity is connected to the `Auth` entity.
|
||||
* 4. We are exposing the `userId` field from the `Auth` entity to
|
||||
* make fetching the User easier.
|
||||
*/
|
||||
export const auth = new Lucia<{}, {
|
||||
userId: User['id']
|
||||
}>(prismaAdapter, {
|
||||
// Since we are not using cookies, we don't need to set any cookie options.
|
||||
// But in the future, if we decide to use cookies, we can set them here.
|
||||
|
||||
// sessionCookie: {
|
||||
// name: "session",
|
||||
// expires: true,
|
||||
// attributes: {
|
||||
// secure: !config.isDevelopment,
|
||||
// sameSite: "lax",
|
||||
// },
|
||||
// },
|
||||
getUserAttributes({ userId }) {
|
||||
return {
|
||||
userId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare module "lucia" {
|
||||
interface Register {
|
||||
Lucia: typeof auth;
|
||||
DatabaseSessionAttributes: {};
|
||||
DatabaseUserAttributes: {
|
||||
userId: User['id']
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Redirect } from 'react-router-dom'
|
||||
import useAuth from '../useAuth'
|
||||
|
||||
|
||||
const createAuthRequiredPage = (Page) => {
|
||||
return (props) => {
|
||||
const { data: user, isError, isSuccess, isLoading } = useAuth()
|
||||
|
||||
if (isSuccess) {
|
||||
if (user) {
|
||||
return (
|
||||
<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
|
||||
|
15
waspc/data/Generator/templates/sdk/wasp/auth/password.ts
Normal file
15
waspc/data/Generator/templates/sdk/wasp/auth/password.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import SecurePassword from 'secure-password'
|
||||
|
||||
const SP = new SecurePassword()
|
||||
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
|
||||
return hashedPwdBuffer.toString("base64")
|
||||
}
|
||||
|
||||
export const verifyPassword = async (hashedPassword: string, password: string): Promise<void> => {
|
||||
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
|
||||
if (result !== SecurePassword.VALID) {
|
||||
throw new Error('Invalid password.')
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import type { Router, Request } from 'express'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { Expand } from 'wasp/universal/types'
|
||||
import type { ProviderName } from '../utils'
|
||||
|
||||
type UserEntityCreateInput = Prisma.UserCreateInput
|
||||
|
||||
export type ProviderConfig = {
|
||||
// Unique provider identifier, used as part of URL paths
|
||||
id: ProviderName;
|
||||
displayName: string;
|
||||
// Each provider config can have an init method which is ran on setup time
|
||||
// e.g. for oAuth providers this is the time when the Passport strategy is registered.
|
||||
init?(provider: ProviderConfig): Promise<InitData>;
|
||||
// Every provider must have a setupRouter method which returns the Express router.
|
||||
// In this function we are flexibile to do what ever is necessary to make the provider work.
|
||||
createRouter(provider: ProviderConfig, initData: InitData): Router;
|
||||
};
|
||||
|
||||
export type InitData = {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
|
||||
|
||||
export type PossibleUserFields = Expand<Partial<UserEntityCreateInput>>
|
||||
|
||||
export type UserSignupFields = {
|
||||
[key in keyof PossibleUserFields]: FieldGetter<
|
||||
PossibleUserFields[key]
|
||||
>
|
||||
}
|
||||
|
||||
type FieldGetter<T> = (
|
||||
data: { [key: string]: unknown }
|
||||
) => Promise<T | undefined> | T | undefined
|
||||
|
||||
export function defineUserSignupFields(fields: UserSignupFields) {
|
||||
return fields
|
||||
}
|
107
waspc/data/Generator/templates/sdk/wasp/auth/session.ts
Normal file
107
waspc/data/Generator/templates/sdk/wasp/auth/session.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { Request as ExpressRequest } from "express";
|
||||
|
||||
import { type User } from "../entities/index.js"
|
||||
import { type SanitizedUser } from '../server/_types/index.js'
|
||||
|
||||
import { auth } from "./lucia.js";
|
||||
import type { Session } from "lucia";
|
||||
import {
|
||||
throwInvalidCredentialsError,
|
||||
deserializeAndSanitizeProviderData,
|
||||
} from "./utils.js";
|
||||
|
||||
import prisma from '../server/dbClient.js'
|
||||
|
||||
// Creates a new session for the `authId` in the database
|
||||
export async function createSession(authId: string): Promise<Session> {
|
||||
return auth.createSession(authId, {});
|
||||
}
|
||||
|
||||
export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{
|
||||
user: SanitizedUser | null,
|
||||
session: Session | null,
|
||||
}> {
|
||||
const authorizationHeader = req.headers["authorization"];
|
||||
|
||||
if (typeof authorizationHeader !== "string") {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId = auth.readBearerToken(authorizationHeader);
|
||||
if (!sessionId) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
return getSessionAndUserFromSessionId(sessionId);
|
||||
}
|
||||
|
||||
export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{
|
||||
user: SanitizedUser | null,
|
||||
session: Session | null,
|
||||
}> {
|
||||
const { session, user: authEntity } = await auth.validateSession(sessionId);
|
||||
|
||||
if (!session || !authEntity) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user: await getUser(authEntity.userId)
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser(userId: User['id']): Promise<SanitizedUser> {
|
||||
const user = await prisma.user
|
||||
.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
auth: {
|
||||
include: {
|
||||
identities: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throwInvalidCredentialsError()
|
||||
}
|
||||
|
||||
// TODO: This logic must match the type in _types/index.ts (if we remove the
|
||||
// password field from the object here, we must to do the same there).
|
||||
// Ideally, these two things would live in the same place:
|
||||
// https://github.com/wasp-lang/wasp/issues/965
|
||||
const deserializedIdentities = user.auth.identities.map((identity) => {
|
||||
const deserializedProviderData = deserializeAndSanitizeProviderData(
|
||||
identity.providerData,
|
||||
{
|
||||
shouldRemovePasswordField: true,
|
||||
}
|
||||
)
|
||||
return {
|
||||
...identity,
|
||||
providerData: deserializedProviderData,
|
||||
}
|
||||
})
|
||||
return {
|
||||
...user,
|
||||
auth: {
|
||||
...user.auth,
|
||||
identities: deserializedIdentities,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateSession(sessionId: string): Promise<void> {
|
||||
return auth.invalidateSession(sessionId);
|
||||
}
|
9
waspc/data/Generator/templates/sdk/wasp/auth/signup.ts
Normal file
9
waspc/data/Generator/templates/sdk/wasp/auth/signup.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import api, { handleApiError } from 'wasp/api'
|
||||
|
||||
export default async function signup(userFields: { username: string; password: string }): Promise<void> {
|
||||
try {
|
||||
await api.post('/auth/username/signup', userFields)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
2
waspc/data/Generator/templates/sdk/wasp/auth/types.ts
Normal file
2
waspc/data/Generator/templates/sdk/wasp/auth/types.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// todo(filip): turn into a proper import/path
|
||||
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/'
|
38
waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts
Normal file
38
waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { deserialize as superjsonDeserialize } from 'superjson'
|
||||
import { useQuery } from 'wasp/rpc'
|
||||
import api, { handleApiError } from 'wasp/api'
|
||||
import { HttpMethod } from 'wasp/types'
|
||||
import type { User } from './types'
|
||||
import { addMetadataToQuery } from 'wasp/rpc/queries'
|
||||
|
||||
export const getMe = createUserGetter()
|
||||
|
||||
export default function useAuth(queryFnArgs?: unknown, config?: any) {
|
||||
return useQuery(getMe, queryFnArgs, config)
|
||||
}
|
||||
|
||||
function createUserGetter() {
|
||||
const getMeRelativePath = 'auth/me'
|
||||
const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
|
||||
async function getMe(): Promise<User | null> {
|
||||
try {
|
||||
const response = await api.get(getMeRoute.path)
|
||||
|
||||
return superjsonDeserialize(response.data)
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
return null
|
||||
} else {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addMetadataToQuery(getMe, {
|
||||
relativeQueryPath: getMeRelativePath,
|
||||
queryRoute: getMeRoute,
|
||||
entitiesUsed: ['User'],
|
||||
})
|
||||
|
||||
return getMe
|
||||
}
|
27
waspc/data/Generator/templates/sdk/wasp/auth/user.ts
Normal file
27
waspc/data/Generator/templates/sdk/wasp/auth/user.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// We decided not to deduplicate these helper functions in the server and the client.
|
||||
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
|
||||
// If you are changing the logic here, make sure to change it there as well.
|
||||
|
||||
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
|
||||
|
||||
export function getEmail(user: User): string | null {
|
||||
return findUserIdentity(user, "email")?.providerUserId ?? null;
|
||||
}
|
||||
|
||||
export function getUsername(user: User): string | null {
|
||||
return findUserIdentity(user, "username")?.providerUserId ?? null;
|
||||
}
|
||||
|
||||
export function getFirstProviderUserId(user?: User): string | null {
|
||||
if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.auth.identities[0].providerUserId ?? null;
|
||||
}
|
||||
|
||||
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
|
||||
return user.auth.identities.find(
|
||||
(identity) => identity.providerName === providerName
|
||||
);
|
||||
}
|
302
waspc/data/Generator/templates/sdk/wasp/auth/utils.ts
Normal file
302
waspc/data/Generator/templates/sdk/wasp/auth/utils.ts
Normal file
@ -0,0 +1,302 @@
|
||||
import { hashPassword } from './password.js'
|
||||
import { verify } from './jwt.js'
|
||||
import AuthError from '../core/AuthError.js'
|
||||
import HttpError from '../core/HttpError.js'
|
||||
import prisma from '../server/dbClient.js'
|
||||
import { sleep } from '../server/utils'
|
||||
import {
|
||||
type User,
|
||||
type Auth,
|
||||
type AuthIdentity,
|
||||
} from '../entities'
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { throwValidationError } from './validation.js'
|
||||
|
||||
import { type UserSignupFields, type PossibleUserFields } from './providers/types.js'
|
||||
|
||||
export type EmailProviderData = {
|
||||
hashedPassword: string;
|
||||
isEmailVerified: boolean;
|
||||
emailVerificationSentAt: string | null;
|
||||
passwordResetSentAt: string | null;
|
||||
}
|
||||
|
||||
export type UsernameProviderData = {
|
||||
hashedPassword: string;
|
||||
}
|
||||
|
||||
export type OAuthProviderData = {}
|
||||
|
||||
/**
|
||||
* This type is used for type-level programming e.g. to enumerate
|
||||
* all possible provider data types.
|
||||
*
|
||||
* The keys of this type are the names of the providers and the values
|
||||
* are the types of the provider data.
|
||||
*/
|
||||
export type PossibleProviderData = {
|
||||
email: EmailProviderData;
|
||||
username: UsernameProviderData;
|
||||
google: OAuthProviderData;
|
||||
github: OAuthProviderData;
|
||||
}
|
||||
|
||||
export type ProviderName = keyof PossibleProviderData
|
||||
|
||||
export const contextWithUserEntity = {
|
||||
entities: {
|
||||
User: prisma.user
|
||||
}
|
||||
}
|
||||
|
||||
export const authConfig = {
|
||||
failureRedirectPath: "/login",
|
||||
successRedirectPath: "/",
|
||||
}
|
||||
|
||||
/**
|
||||
* ProviderId uniquely identifies an auth identity e.g.
|
||||
* "email" provider with user id "test@test.com" or
|
||||
* "google" provider with user id "1234567890".
|
||||
*
|
||||
* We use this type to avoid passing the providerName and providerUserId
|
||||
* separately. Also, we can normalize the providerUserId to make sure it's
|
||||
* consistent across different DB operations.
|
||||
*/
|
||||
export type ProviderId = {
|
||||
providerName: ProviderName;
|
||||
providerUserId: string;
|
||||
}
|
||||
|
||||
export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId {
|
||||
return {
|
||||
providerName,
|
||||
providerUserId: providerUserId.toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAuthIdentity(providerId: ProviderId): Promise<AuthIdentity | null> {
|
||||
return prisma.authIdentity.findUnique({
|
||||
where: {
|
||||
providerName_providerUserId: providerId,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the provider data for the given auth identity.
|
||||
*
|
||||
* This function performs data sanitization and serialization.
|
||||
* Sanitization is done by hashing the password, so this function
|
||||
* expects the password received in the `providerDataUpdates`
|
||||
* **not to be hashed**.
|
||||
*/
|
||||
export async function updateAuthIdentityProviderData<PN extends ProviderName>(
|
||||
providerId: ProviderId,
|
||||
existingProviderData: PossibleProviderData[PN],
|
||||
providerDataUpdates: Partial<PossibleProviderData[PN]>,
|
||||
): Promise<AuthIdentity> {
|
||||
// We are doing the sanitization here only on updates to avoid
|
||||
// hashing the password multiple times.
|
||||
const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates);
|
||||
const newProviderData = {
|
||||
...existingProviderData,
|
||||
...sanitizedProviderDataUpdates,
|
||||
}
|
||||
const serializedProviderData = await serializeProviderData<PN>(newProviderData);
|
||||
return prisma.authIdentity.update({
|
||||
where: {
|
||||
providerName_providerUserId: providerId,
|
||||
},
|
||||
data: { providerData: serializedProviderData },
|
||||
});
|
||||
}
|
||||
|
||||
type FindAuthWithUserResult = Auth & {
|
||||
user: User
|
||||
}
|
||||
|
||||
export async function findAuthWithUserBy(
|
||||
where: Prisma.AuthWhereInput
|
||||
): Promise<FindAuthWithUserResult> {
|
||||
return prisma.auth.findFirst({ where, include: { user: true }});
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
providerId: ProviderId,
|
||||
serializedProviderData?: string,
|
||||
userFields?: PossibleUserFields,
|
||||
): Promise<User & {
|
||||
auth: Auth
|
||||
}> {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
// Using any here to prevent type errors when userFields are not
|
||||
// defined. We want Prisma to throw an error in that case.
|
||||
...(userFields ?? {} as any),
|
||||
auth: {
|
||||
create: {
|
||||
identities: {
|
||||
create: {
|
||||
providerName: providerId.providerName,
|
||||
providerUserId: providerId.providerUserId,
|
||||
providerData: serializedProviderData,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
// We need to include the Auth entity here because we need `authId`
|
||||
// to be able to create a session.
|
||||
include: {
|
||||
auth: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> {
|
||||
return prisma.user.deleteMany({ where: { auth: {
|
||||
id: authId,
|
||||
} } })
|
||||
}
|
||||
|
||||
export async function verifyToken<T = unknown>(token: string): Promise<T> {
|
||||
return verify(token);
|
||||
}
|
||||
|
||||
// If an user exists, we don't want to leak information
|
||||
// about it. Pretending that we're doing some work
|
||||
// will make it harder for an attacker to determine
|
||||
// if a user exists or not.
|
||||
// NOTE: Attacker measuring time to response can still determine
|
||||
// if a user exists or not. We'll be able to avoid it when
|
||||
// we implement e-mail sending via jobs.
|
||||
export async function doFakeWork(): Promise<unknown> {
|
||||
const timeToWork = Math.floor(Math.random() * 1000) + 1000;
|
||||
return sleep(timeToWork);
|
||||
}
|
||||
|
||||
export function rethrowPossibleAuthError(e: unknown): void {
|
||||
if (e instanceof AuthError) {
|
||||
throwValidationError(e.message);
|
||||
}
|
||||
|
||||
// Prisma code P2002 is for unique constraint violations.
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new HttpError(422, 'Save failed', {
|
||||
message: `user with the same identity already exists`,
|
||||
})
|
||||
}
|
||||
|
||||
if (e instanceof Prisma.PrismaClientValidationError) {
|
||||
// NOTE: Logging the error since this usually means that there are
|
||||
// required fields missing in the request, we want the developer
|
||||
// to know about it.
|
||||
console.error(e)
|
||||
throw new HttpError(422, 'Save failed', {
|
||||
message: 'there was a database error'
|
||||
})
|
||||
}
|
||||
|
||||
// Prisma code P2021 is for missing table errors.
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') {
|
||||
// NOTE: Logging the error since this usually means that the database
|
||||
// migrations weren't run, we want the developer to know about it.
|
||||
console.error(e)
|
||||
console.info('🐝 This error can happen if you did\'t run the database migrations.')
|
||||
throw new HttpError(500, 'Save failed', {
|
||||
message: `there was a database error`,
|
||||
})
|
||||
}
|
||||
|
||||
// Prisma code P2003 is for foreign key constraint failure
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') {
|
||||
console.error(e)
|
||||
console.info(`🐝 This error can happen if you have some relation on your User entity
|
||||
but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull".
|
||||
Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`)
|
||||
throw new HttpError(500, 'Save failed', {
|
||||
message: `there was a database error`,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
|
||||
export async function validateAndGetUserFields(
|
||||
data: {
|
||||
[key: string]: unknown
|
||||
},
|
||||
userSignupFields?: UserSignupFields,
|
||||
): Promise<Record<string, any>> {
|
||||
const {
|
||||
password: _password,
|
||||
...sanitizedData
|
||||
} = data;
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
if (!userSignupFields) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const [field, getFieldValue] of Object.entries(userSignupFields)) {
|
||||
try {
|
||||
const value = await getFieldValue(sanitizedData)
|
||||
result[field] = value
|
||||
} catch (e) {
|
||||
throwValidationError(e.message)
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function deserializeAndSanitizeProviderData<PN extends ProviderName>(
|
||||
providerData: string,
|
||||
{ shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {},
|
||||
): PossibleProviderData[PN] {
|
||||
// NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON.
|
||||
let data = JSON.parse(providerData) as PossibleProviderData[PN];
|
||||
|
||||
if (providerDataHasPasswordField(data) && shouldRemovePasswordField) {
|
||||
delete data.hashedPassword;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sanitizeAndSerializeProviderData<PN extends ProviderName>(
|
||||
providerData: PossibleProviderData[PN],
|
||||
): Promise<string> {
|
||||
return serializeProviderData(
|
||||
await sanitizeProviderData(providerData)
|
||||
);
|
||||
}
|
||||
|
||||
function serializeProviderData<PN extends ProviderName>(providerData: PossibleProviderData[PN]): string {
|
||||
return JSON.stringify(providerData);
|
||||
}
|
||||
|
||||
async function sanitizeProviderData<PN extends ProviderName>(
|
||||
providerData: PossibleProviderData[PN],
|
||||
): Promise<PossibleProviderData[PN]> {
|
||||
const data = {
|
||||
...providerData,
|
||||
};
|
||||
if (providerDataHasPasswordField(data)) {
|
||||
data.hashedPassword = await hashPassword(data.hashedPassword);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
function providerDataHasPasswordField(
|
||||
providerData: PossibleProviderData[keyof PossibleProviderData],
|
||||
): providerData is { hashedPassword: string } {
|
||||
return 'hashedPassword' in providerData;
|
||||
}
|
||||
|
||||
export function throwInvalidCredentialsError(message?: string): void {
|
||||
throw new HttpError(401, 'Invalid credentials', { message })
|
||||
}
|
77
waspc/data/Generator/templates/sdk/wasp/auth/validation.ts
Normal file
77
waspc/data/Generator/templates/sdk/wasp/auth/validation.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import HttpError from '../core/HttpError.js';
|
||||
|
||||
export const PASSWORD_FIELD = 'password';
|
||||
const USERNAME_FIELD = 'username';
|
||||
const EMAIL_FIELD = 'email';
|
||||
const TOKEN_FIELD = 'token';
|
||||
|
||||
export function ensureValidEmail(args: unknown): void {
|
||||
validate(args, [
|
||||
{ validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email },
|
||||
{ validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) },
|
||||
]);
|
||||
}
|
||||
|
||||
export function ensureValidUsername(args: unknown): void {
|
||||
validate(args, [
|
||||
{ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username }
|
||||
]);
|
||||
}
|
||||
|
||||
export function ensurePasswordIsPresent(args: unknown): void {
|
||||
validate(args, [
|
||||
{ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password },
|
||||
]);
|
||||
}
|
||||
|
||||
export function ensureValidPassword(args: unknown): void {
|
||||
validate(args, [
|
||||
{ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => isMinLength(password, 8) },
|
||||
{ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => containsNumber(password) },
|
||||
]);
|
||||
}
|
||||
|
||||
export function ensureTokenIsPresent(args: unknown): void {
|
||||
validate(args, [
|
||||
{ validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token },
|
||||
]);
|
||||
}
|
||||
|
||||
export function throwValidationError(message: string): void {
|
||||
throw new HttpError(422, 'Validation failed', { message })
|
||||
}
|
||||
|
||||
function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void {
|
||||
for (const { validates, message, validator } of validators) {
|
||||
if (!validator(args[validates])) {
|
||||
throwValidationError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE(miho): it would be good to replace our custom validations with e.g. Zod
|
||||
|
||||
const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/
|
||||
function isValidEmail(input: unknown): boolean {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
return input.match(validEmailRegex) !== null
|
||||
}
|
||||
|
||||
function isMinLength(input: unknown, minLength: number): boolean {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
return input.length >= minLength
|
||||
}
|
||||
|
||||
function containsNumber(input: unknown): boolean {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
return /\d/.test(input)
|
||||
}
|
41
waspc/data/Generator/templates/sdk/wasp/core/auth.js
Normal file
41
waspc/data/Generator/templates/sdk/wasp/core/auth.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { randomInt } from 'node:crypto'
|
||||
|
||||
import prisma from '../server/dbClient.js'
|
||||
import { handleRejection } from '../utils.js'
|
||||
import { getSessionAndUserFromBearerToken } from 'wasp/auth/session'
|
||||
import { throwInvalidCredentialsError } from 'wasp/auth/utils'
|
||||
|
||||
/**
|
||||
* Auth middleware
|
||||
*
|
||||
* If the request includes an `Authorization` header it will try to authenticate the request,
|
||||
* otherwise it will let the request through.
|
||||
*
|
||||
* - If authentication succeeds it sets `req.sessionId` and `req.user`
|
||||
* - `req.user` is the user that made the request and it's used in
|
||||
* all Wasp features that need to know the user that made the request.
|
||||
* - `req.sessionId` is the ID of the session that authenticated the request.
|
||||
* - If the request is not authenticated, it throws an error.
|
||||
*/
|
||||
const auth = handleRejection(async (req, res, next) => {
|
||||
const authHeader = req.get('Authorization')
|
||||
if (!authHeader) {
|
||||
// NOTE(matija): for now we let tokenless requests through and make it operation's
|
||||
// responsibility to verify whether the request is authenticated or not. In the future
|
||||
// we will develop our own system at Wasp-level for that.
|
||||
return next()
|
||||
}
|
||||
|
||||
const { session, user } = await getSessionAndUserFromBearerToken(req);
|
||||
|
||||
if (!session || !user) {
|
||||
throwInvalidCredentialsError()
|
||||
}
|
||||
|
||||
req.sessionId = session.id
|
||||
req.user = user
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default auth
|
9
waspc/data/Generator/templates/sdk/wasp/core/config.js
Normal file
9
waspc/data/Generator/templates/sdk/wasp/core/config.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { stripTrailingSlash } from 'wasp/universal/url'
|
||||
|
||||
const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || 'http://localhost:3001';
|
||||
|
||||
const config = {
|
||||
apiUrl,
|
||||
}
|
||||
|
||||
export default config
|
@ -0,0 +1,33 @@
|
||||
import { createStitches } from '@stitches/react'
|
||||
|
||||
export const {
|
||||
styled,
|
||||
css
|
||||
} = createStitches({
|
||||
theme: {
|
||||
colors: {
|
||||
waspYellow: '#ffcc00',
|
||||
gray700: '#a1a5ab',
|
||||
gray600: '#d1d5db',
|
||||
gray500: 'gainsboro',
|
||||
gray400: '#f0f0f0',
|
||||
red: '#FED7D7',
|
||||
darkRed: '#fa3838',
|
||||
green: '#C6F6D5',
|
||||
|
||||
brand: '$waspYellow',
|
||||
brandAccent: '#ffdb46',
|
||||
errorBackground: '$red',
|
||||
errorText: '#2D3748',
|
||||
successBackground: '$green',
|
||||
successText: '#2D3748',
|
||||
|
||||
submitButtonText: 'black',
|
||||
|
||||
formErrorText: '$darkRed',
|
||||
},
|
||||
fontSizes: {
|
||||
sm: '0.875rem'
|
||||
}
|
||||
}
|
||||
})
|
50
waspc/data/Generator/templates/sdk/wasp/core/storage.ts
Normal file
50
waspc/data/Generator/templates/sdk/wasp/core/storage.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export type DataStore = {
|
||||
getPrefixedKey(key: string): string
|
||||
set(key: string, value: unknown): void
|
||||
get(key: string): unknown
|
||||
remove(key: string): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
function createLocalStorageDataStore(prefix: string): DataStore {
|
||||
function getPrefixedKey(key: string): string {
|
||||
return `${prefix}:${key}`
|
||||
}
|
||||
|
||||
return {
|
||||
getPrefixedKey,
|
||||
set(key, value) {
|
||||
ensureLocalStorageIsAvailable()
|
||||
localStorage.setItem(getPrefixedKey(key), JSON.stringify(value))
|
||||
},
|
||||
get(key) {
|
||||
ensureLocalStorageIsAvailable()
|
||||
const value = localStorage.getItem(getPrefixedKey(key))
|
||||
try {
|
||||
return value ? JSON.parse(value) : undefined
|
||||
} catch (e: any) {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
remove(key) {
|
||||
ensureLocalStorageIsAvailable()
|
||||
localStorage.removeItem(getPrefixedKey(key))
|
||||
},
|
||||
clear() {
|
||||
ensureLocalStorageIsAvailable()
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = createLocalStorageDataStore('wasp')
|
||||
|
||||
function ensureLocalStorageIsAvailable(): void {
|
||||
if (!window.localStorage) {
|
||||
throw new Error('Local storage is not available.')
|
||||
}
|
||||
}
|
21
waspc/data/Generator/templates/sdk/wasp/entities/index.ts
Normal file
21
waspc/data/Generator/templates/sdk/wasp/entities/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
type User,
|
||||
type Task,
|
||||
} from "@prisma/client"
|
||||
|
||||
export {
|
||||
type User,
|
||||
type Task,
|
||||
type Auth,
|
||||
type AuthIdentity,
|
||||
} from "@prisma/client"
|
||||
|
||||
export type Entity =
|
||||
| User
|
||||
| Task
|
||||
| never
|
||||
|
||||
export type EntityName =
|
||||
| "User"
|
||||
| "Task"
|
||||
| never
|
56
waspc/data/Generator/templates/sdk/wasp/ext-src/actions.ts
Normal file
56
waspc/data/Generator/templates/sdk/wasp/ext-src/actions.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
import type {
|
||||
CreateTask,
|
||||
UpdateTask,
|
||||
DeleteTasks,
|
||||
} from 'wasp/server/actions/types'
|
||||
import type { Task } from 'wasp/entities'
|
||||
|
||||
type CreateArgs = Pick<Task, 'description'>
|
||||
|
||||
export const createTask: CreateTask<CreateArgs, Task> = async (
|
||||
{ description },
|
||||
context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
return context.entities.Task.create({
|
||||
data: {
|
||||
description,
|
||||
user: { connect: { id: context.user.id } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateArgs = Pick<Task, 'id' | 'isDone'>
|
||||
|
||||
export const updateTask: UpdateTask<UpdateArgs> = async (
|
||||
{ id, isDone },
|
||||
context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
return context.entities.Task.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: { isDone },
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteTasks: DeleteTasks<Task['id'][]> = async (
|
||||
idsToDelete,
|
||||
context
|
||||
) => {
|
||||
return context.entities.Task.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: idsToDelete,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
15
waspc/data/Generator/templates/sdk/wasp/ext-src/queries.ts
Normal file
15
waspc/data/Generator/templates/sdk/wasp/ext-src/queries.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
import type { GetTasks } from 'wasp/server/queries/types'
|
||||
import type { Task } from 'wasp/entities'
|
||||
|
||||
//Using TypeScript's new 'satisfies' keyword, it will infer the types of the arguments and return value
|
||||
export const getTasks = ((_args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
return context.entities.Task.findMany({
|
||||
where: { user: { id: context.user.id } },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
}) satisfies GetTasks<void, Task[]>
|
22
waspc/data/Generator/templates/sdk/wasp/operations/index.ts
Normal file
22
waspc/data/Generator/templates/sdk/wasp/operations/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import api, { handleApiError } from 'wasp/api'
|
||||
import { HttpMethod } from 'wasp/types'
|
||||
import {
|
||||
serialize as superjsonSerialize,
|
||||
deserialize as superjsonDeserialize,
|
||||
} from 'superjson'
|
||||
|
||||
export type OperationRoute = { method: HttpMethod, path: string }
|
||||
|
||||
export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
|
||||
try {
|
||||
const superjsonArgs = superjsonSerialize(args)
|
||||
const response = await api.post(operationRoute.path, superjsonArgs)
|
||||
return superjsonDeserialize(response.data)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
|
||||
return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import { queryClientInitialized } from 'wasp/rpc/queryClient'
|
||||
import { makeUpdateHandlersMap } from './updateHandlersMap'
|
||||
import { hashQueryKey } from '@tanstack/react-query'
|
||||
|
||||
// Map where key is resource name and value is Set
|
||||
// containing query ids of all the queries that use
|
||||
// that resource.
|
||||
const resourceToQueryCacheKeys = new Map()
|
||||
|
||||
const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
|
||||
/**
|
||||
* Remembers that specified query is using specified resources.
|
||||
* If called multiple times for same query, resources are added, not reset.
|
||||
* @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
|
||||
* @param {string[]} resources - Names of resources that query is using.
|
||||
*/
|
||||
export function addResourcesUsedByQuery(queryCacheKey, resources) {
|
||||
for (const resource of resources) {
|
||||
let cacheKeys = resourceToQueryCacheKeys.get(resource)
|
||||
if (!cacheKeys) {
|
||||
cacheKeys = new Set()
|
||||
resourceToQueryCacheKeys.set(resource, cacheKeys)
|
||||
}
|
||||
cacheKeys.add(queryCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerActionInProgress(optimisticUpdateTuples) {
|
||||
optimisticUpdateTuples.forEach(
|
||||
({ queryKey, updateQuery }) => updateHandlers.add(queryKey, updateQuery)
|
||||
)
|
||||
}
|
||||
|
||||
export async function registerActionDone(resources, optimisticUpdateTuples) {
|
||||
optimisticUpdateTuples.forEach(({ queryKey }) => updateHandlers.remove(queryKey))
|
||||
await invalidateQueriesUsing(resources)
|
||||
}
|
||||
|
||||
export function getActiveOptimisticUpdates(queryKey) {
|
||||
return updateHandlers.getUpdateHandlers(queryKey)
|
||||
}
|
||||
|
||||
export async function invalidateAndRemoveQueries() {
|
||||
const queryClient = await queryClientInitialized
|
||||
// If we don't reset the queries before removing them, Wasp will stay on
|
||||
// the same page. The user would have to manually refresh the page to "finish"
|
||||
// logging out.
|
||||
// When a query is removed, the `Observer` is removed as well, and the components
|
||||
// that are using the query are not re-rendered. This is why we need to reset
|
||||
// the queries, so that the `Observer` is re-created and the components are re-rendered.
|
||||
// For more details: https://github.com/wasp-lang/wasp/pull/1014/files#r1111862125
|
||||
queryClient.resetQueries()
|
||||
// If we don't remove the queries after invalidating them, the old query data
|
||||
// remains in the cache, casuing a potential privacy issue.
|
||||
queryClient.removeQueries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates all queries that are using specified resources.
|
||||
* @param {string[]} resources - Names of resources.
|
||||
*/
|
||||
async function invalidateQueriesUsing(resources) {
|
||||
const queryClient = await queryClientInitialized
|
||||
|
||||
const queryCacheKeysToInvalidate = getQueriesUsingResources(resources)
|
||||
queryCacheKeysToInvalidate.forEach(
|
||||
queryCacheKey => queryClient.invalidateQueries(queryCacheKey)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} resource - Resource name.
|
||||
* @returns {string[]} Array of "query cache keys" of queries that use specified resource.
|
||||
*/
|
||||
function getQueriesUsingResource(resource) {
|
||||
return Array.from(resourceToQueryCacheKeys.get(resource) || [])
|
||||
}
|
||||
|
||||
function getQueriesUsingResources(resources) {
|
||||
return Array.from(new Set(resources.flatMap(getQueriesUsingResource)))
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
export function makeUpdateHandlersMap(calculateHash) {
|
||||
const updateHandlers = new Map()
|
||||
|
||||
function getHandlerTuples(queryKeyHash) {
|
||||
return updateHandlers.get(queryKeyHash) || [];
|
||||
}
|
||||
|
||||
function add(queryKey, updateQuery) {
|
||||
const queryKeyHash = calculateHash(queryKey)
|
||||
const handlers = getHandlerTuples(queryKeyHash);
|
||||
updateHandlers.set(queryKeyHash, [...handlers, { queryKey, updateQuery }])
|
||||
}
|
||||
|
||||
function getUpdateHandlers(queryKey) {
|
||||
const queryKeyHash = calculateHash(queryKey)
|
||||
return getHandlerTuples(queryKeyHash).map(({ updateQuery }) => updateQuery)
|
||||
}
|
||||
|
||||
function remove(queryKeyToRemove) {
|
||||
const queryKeyHash = calculateHash(queryKeyToRemove)
|
||||
const filteredHandlers = getHandlerTuples(queryKeyHash).filter(
|
||||
({ queryKey }) => queryKey !== queryKeyToRemove
|
||||
)
|
||||
|
||||
if (filteredHandlers.length > 0) {
|
||||
updateHandlers.set(queryKeyHash, filteredHandlers)
|
||||
} else {
|
||||
updateHandlers.delete(queryKeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
getUpdateHandlers,
|
||||
}
|
||||
}
|
13
waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.d.ts
vendored
Normal file
13
waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
import { type Action } from '.'
|
||||
import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types'
|
||||
|
||||
export function createAction<BackendAction extends GenericBackendAction>(
|
||||
actionRoute: string,
|
||||
entitiesUsed: unknown[]
|
||||
): ActionFor<BackendAction>
|
||||
|
||||
type ActionFor<BackendAction extends GenericBackendAction> = Expand<
|
||||
Action<Parameters<BackendAction>[0], _Awaited<_ReturnType<BackendAction>>>
|
||||
>
|
||||
|
||||
type GenericBackendAction = (args: never, context: any) => unknown
|
37
waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.js
Normal file
37
waspc/data/Generator/templates/sdk/wasp/rpc/actions/core.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { callOperation, makeOperationRoute } from 'wasp/operations'
|
||||
import {
|
||||
registerActionInProgress,
|
||||
registerActionDone,
|
||||
} from 'wasp/operations/resources'
|
||||
|
||||
// todo(filip) - turn helpers and core into the same thing
|
||||
|
||||
export function createAction(relativeActionRoute, entitiesUsed) {
|
||||
const actionRoute = makeOperationRoute(relativeActionRoute)
|
||||
|
||||
async function internalAction(args, specificOptimisticUpdateDefinitions) {
|
||||
registerActionInProgress(specificOptimisticUpdateDefinitions)
|
||||
try {
|
||||
// The `return await` is not redundant here. If we removed the await, the
|
||||
// `finally` block would execute before the action finishes, prematurely
|
||||
// registering the action as done.
|
||||
return await callOperation(actionRoute, args)
|
||||
} finally {
|
||||
await registerActionDone(entitiesUsed, specificOptimisticUpdateDefinitions)
|
||||
}
|
||||
}
|
||||
|
||||
// We expose (and document) a restricted version of the API for our users,
|
||||
// while also attaching the full "internal" API to the exposed action. By
|
||||
// doing this, we can easily use the internal API of an action a users passes
|
||||
// into our system (e.g., through the `useAction` hook) without needing a
|
||||
// lookup table.
|
||||
//
|
||||
// While it does technically allow our users to access the interal API, it
|
||||
// shouldn't be a problem in practice. Still, if it turns out to be a problem,
|
||||
// we can always hide it using a Symbol.
|
||||
const action = (args) => internalAction(args, [])
|
||||
action.internal = internalAction
|
||||
|
||||
return action
|
||||
}
|
14
waspc/data/Generator/templates/sdk/wasp/rpc/actions/index.ts
Normal file
14
waspc/data/Generator/templates/sdk/wasp/rpc/actions/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createAction } from './core'
|
||||
import { CreateTask, UpdateTask } from 'wasp/server/actions'
|
||||
|
||||
export const updateTask = createAction<UpdateTask>('operations/update-task', [
|
||||
'Task',
|
||||
])
|
||||
|
||||
export const createTask = createAction<CreateTask>('operations/create-task', [
|
||||
'Task',
|
||||
])
|
||||
|
||||
export const deleteTasks = createAction<CreateTask>('operations/delete-tasks', [
|
||||
'Task',
|
||||
])
|
338
waspc/data/Generator/templates/sdk/wasp/rpc/index.ts
Normal file
338
waspc/data/Generator/templates/sdk/wasp/rpc/index.ts
Normal file
@ -0,0 +1,338 @@
|
||||
import {
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useMutation,
|
||||
UseMutationOptions,
|
||||
useQueryClient,
|
||||
useQuery as rqUseQuery,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
export { configureQueryClient } from "./queryClient";
|
||||
|
||||
export type Query<Input, Output> = {
|
||||
(queryCacheKey: string[], args: Input): Promise<Output>;
|
||||
};
|
||||
|
||||
export function useQuery<Input, Output>(
|
||||
queryFn: Query<Input, Output>,
|
||||
queryFnArgs?: Input,
|
||||
options?: any
|
||||
): UseQueryResult<Output, Error>;
|
||||
|
||||
export function useQuery(queryFn, queryFnArgs, options) {
|
||||
if (typeof queryFn !== "function") {
|
||||
throw new TypeError("useQuery requires queryFn to be a function.");
|
||||
}
|
||||
if (!queryFn.queryCacheKey) {
|
||||
throw new TypeError(
|
||||
"queryFn needs to have queryCacheKey property defined."
|
||||
);
|
||||
}
|
||||
|
||||
const queryKey =
|
||||
queryFnArgs !== undefined
|
||||
? [...queryFn.queryCacheKey, queryFnArgs]
|
||||
: queryFn.queryCacheKey;
|
||||
return rqUseQuery({
|
||||
queryKey,
|
||||
queryFn: () => queryFn(queryKey, queryFnArgs),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
// todo - turn helpers and core into the same thing
|
||||
|
||||
export type Action<Input, Output> = [Input] extends [never]
|
||||
? (args?: unknown) => Promise<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];
|
||||
}
|
23
waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.d.ts
vendored
Normal file
23
waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.d.ts
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
import { type Query } from '..'
|
||||
import { Route } from 'wasp/types'
|
||||
import type { Expand, _Awaited, _ReturnType } from 'wasp/universal/types'
|
||||
|
||||
export function createQuery<BackendQuery extends GenericBackendQuery>(
|
||||
queryRoute: string,
|
||||
entitiesUsed: any[]
|
||||
): QueryFor<BackendQuery>
|
||||
|
||||
export function addMetadataToQuery(
|
||||
query: (...args: any[]) => Promise<unknown>,
|
||||
metadata: {
|
||||
relativeQueryPath: string
|
||||
queryRoute: Route
|
||||
entitiesUsed: string[]
|
||||
}
|
||||
): void
|
||||
|
||||
type QueryFor<BackendQuery extends GenericBackendQuery> = Expand<
|
||||
Query<Parameters<BackendQuery>[0], _Awaited<_ReturnType<BackendQuery>>>
|
||||
>
|
||||
|
||||
type GenericBackendQuery = (args: never, context: any) => unknown
|
30
waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.js
Normal file
30
waspc/data/Generator/templates/sdk/wasp/rpc/queries/core.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { callOperation, makeOperationRoute } from 'wasp/operations'
|
||||
import {
|
||||
addResourcesUsedByQuery,
|
||||
getActiveOptimisticUpdates,
|
||||
} from 'wasp/operations/resources'
|
||||
|
||||
export function createQuery(relativeQueryPath, entitiesUsed) {
|
||||
const queryRoute = makeOperationRoute(relativeQueryPath)
|
||||
|
||||
async function query(queryKey, queryArgs) {
|
||||
const serverResult = await callOperation(queryRoute, queryArgs)
|
||||
return getActiveOptimisticUpdates(queryKey).reduce(
|
||||
(result, update) => update(result),
|
||||
serverResult,
|
||||
)
|
||||
}
|
||||
|
||||
addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed })
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export function addMetadataToQuery(
|
||||
query,
|
||||
{ relativeQueryPath, queryRoute, entitiesUsed }
|
||||
) {
|
||||
query.queryCacheKey = [relativeQueryPath]
|
||||
query.route = queryRoute
|
||||
addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { createQuery } from './core'
|
||||
import { GetTasks } from 'wasp/server/queries'
|
||||
|
||||
export const getTasks = createQuery<GetTasks>('operations/get-tasks', ['Task'])
|
||||
|
||||
export { addMetadataToQuery } from './core'
|
33
waspc/data/Generator/templates/sdk/wasp/rpc/queryClient.ts
Normal file
33
waspc/data/Generator/templates/sdk/wasp/rpc/queryClient.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
type QueryClientConfig = object;
|
||||
|
||||
const defaultQueryClientConfig = {};
|
||||
|
||||
let queryClientConfig: QueryClientConfig,
|
||||
resolveQueryClientInitialized: (...args: any[]) => any,
|
||||
isQueryClientInitialized: boolean;
|
||||
|
||||
export const queryClientInitialized: Promise<QueryClient> = new Promise(
|
||||
(resolve) => {
|
||||
resolveQueryClientInitialized = resolve;
|
||||
}
|
||||
);
|
||||
|
||||
export function configureQueryClient(config: QueryClientConfig): void {
|
||||
if (isQueryClientInitialized) {
|
||||
throw new Error(
|
||||
"Attempted to configure the QueryClient after initialization"
|
||||
);
|
||||
}
|
||||
|
||||
queryClientConfig = config;
|
||||
}
|
||||
|
||||
export function initializeQueryClient(): void {
|
||||
const queryClient = new QueryClient(
|
||||
queryClientConfig ?? defaultQueryClientConfig
|
||||
);
|
||||
isQueryClientInitialized = true;
|
||||
resolveQueryClientInitialized(queryClient);
|
||||
}
|
101
waspc/data/Generator/templates/sdk/wasp/server/_types/index.ts
Normal file
101
waspc/data/Generator/templates/sdk/wasp/server/_types/index.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { type Expand } from 'wasp/universal/types';
|
||||
import { type Request, type Response } from 'express'
|
||||
import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
|
||||
import prisma from "wasp/server/dbClient"
|
||||
import {
|
||||
type User,
|
||||
type Auth,
|
||||
type AuthIdentity,
|
||||
} from "wasp/entities"
|
||||
import {
|
||||
type EmailProviderData,
|
||||
type UsernameProviderData,
|
||||
type OAuthProviderData,
|
||||
// todo(filip): marker
|
||||
} from 'wasp/auth/utils'
|
||||
import { type _Entity } from "./taggedEntities"
|
||||
import { type Payload } from "./serialization";
|
||||
|
||||
export * from "./taggedEntities"
|
||||
export * from "./serialization"
|
||||
|
||||
export type Query<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
|
||||
Operation<Entities, Input, Output>
|
||||
|
||||
export type Action<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
|
||||
Operation<Entities, Input, Output>
|
||||
|
||||
export type AuthenticatedQuery<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
|
||||
AuthenticatedOperation<Entities, Input, Output>
|
||||
|
||||
export type AuthenticatedAction<Entities extends _Entity[], Input extends Payload, Output extends Payload> =
|
||||
AuthenticatedOperation<Entities, Input, Output>
|
||||
|
||||
type AuthenticatedOperation<Entities extends _Entity[], Input extends Payload, Output extends Payload> = (
|
||||
args: Input,
|
||||
context: ContextWithUser<Entities>,
|
||||
) => Output | Promise<Output>
|
||||
|
||||
export type AuthenticatedApi<
|
||||
Entities extends _Entity[],
|
||||
Params extends ExpressParams,
|
||||
ResBody,
|
||||
ReqBody,
|
||||
ReqQuery extends ExpressQuery,
|
||||
Locals extends Record<string, any>
|
||||
> = (
|
||||
req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>,
|
||||
res: Response<ResBody, Locals>,
|
||||
context: ContextWithUser<Entities>,
|
||||
) => void
|
||||
|
||||
type Operation<Entities extends _Entity[], Input, Output> = (
|
||||
args: Input,
|
||||
context: Context<Entities>,
|
||||
) => Output | Promise<Output>
|
||||
|
||||
export type Api<
|
||||
Entities extends _Entity[],
|
||||
Params extends ExpressParams,
|
||||
ResBody,
|
||||
ReqBody,
|
||||
ReqQuery extends ExpressQuery,
|
||||
Locals extends Record<string, any>
|
||||
> = (
|
||||
req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>,
|
||||
res: Response<ResBody, Locals>,
|
||||
context: Context<Entities>,
|
||||
) => void
|
||||
|
||||
type EntityMap<Entities extends _Entity[]> = {
|
||||
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
|
||||
}
|
||||
|
||||
export type PrismaDelegate = {
|
||||
"User": typeof prisma.user,
|
||||
"Task": typeof prisma.task,
|
||||
}
|
||||
|
||||
type Context<Entities extends _Entity[]> = Expand<{
|
||||
entities: Expand<EntityMap<Entities>>
|
||||
}>
|
||||
|
||||
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: SanitizedUser }>
|
||||
|
||||
// TODO: This type must match the logic in auth/session.js (if we remove the
|
||||
// password field from the object there, we must do the same here). Ideally,
|
||||
// these two things would live in the same place:
|
||||
// https://github.com/wasp-lang/wasp/issues/965
|
||||
|
||||
export type DeserializedAuthIdentity = Expand<Omit<AuthIdentity, 'providerData'> & {
|
||||
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
|
||||
}>
|
||||
|
||||
export type SanitizedUser = User & {
|
||||
auth: Auth & {
|
||||
identities: DeserializedAuthIdentity[]
|
||||
} | null
|
||||
}
|
||||
|
||||
// todo(filip): marker
|
||||
export type { ProviderName } from 'wasp/auth/utils'
|
@ -0,0 +1,43 @@
|
||||
export type Payload = void | SuperJSONValue
|
||||
|
||||
// The part below was copied from SuperJSON and slightly modified:
|
||||
// https://github.com/blitz-js/superjson/blob/ae7dbcefe5d3ece5b04be0c6afe6b40f3a44a22a/src/types.ts
|
||||
//
|
||||
// We couldn't use SuperJSON's types directly because:
|
||||
// 1. They aren't exported publicly.
|
||||
// 2. They have a werid quirk that turns `SuperJSONValue` into `any`.
|
||||
// See why here:
|
||||
// https://github.com/blitz-js/superjson/pull/36#issuecomment-669239876
|
||||
//
|
||||
// We changed the code as little as possible to make future comparisons easier.
|
||||
export type JSONValue = PrimitiveJSONValue | JSONArray | JSONObject
|
||||
|
||||
export interface JSONObject {
|
||||
[key: string]: JSONValue
|
||||
}
|
||||
|
||||
type PrimitiveJSONValue = string | number | boolean | undefined | null
|
||||
|
||||
interface JSONArray extends Array<JSONValue> {}
|
||||
|
||||
type SerializableJSONValue =
|
||||
| Symbol
|
||||
| Set<SuperJSONValue>
|
||||
| Map<SuperJSONValue, SuperJSONValue>
|
||||
| undefined
|
||||
| bigint
|
||||
| Date
|
||||
| RegExp
|
||||
|
||||
// Here's where we excluded `ClassInstance` (which was `any`) from the union.
|
||||
type SuperJSONValue =
|
||||
| JSONValue
|
||||
| SerializableJSONValue
|
||||
| SuperJSONArray
|
||||
| SuperJSONObject
|
||||
|
||||
interface SuperJSONArray extends Array<SuperJSONValue> {}
|
||||
|
||||
interface SuperJSONObject {
|
||||
[key: string]: SuperJSONValue
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
// Wasp internally uses the types defined in this file for typing entity maps in
|
||||
// operation contexts.
|
||||
//
|
||||
// We must explicitly tag all entities with their name to avoid issues with
|
||||
// structural typing. See https://github.com/wasp-lang/wasp/pull/982 for details.
|
||||
import {
|
||||
type Entity,
|
||||
type EntityName,
|
||||
type User,
|
||||
type Task,
|
||||
} from '../../entities'
|
||||
|
||||
export type _User = WithName<User, "User">
|
||||
export type _Task = WithName<Task, "Task">
|
||||
|
||||
export type _Entity =
|
||||
| _User
|
||||
| _Task
|
||||
| never
|
||||
|
||||
type WithName<E extends Entity, Name extends EntityName> =
|
||||
E & { _entityName: Name }
|
@ -0,0 +1,39 @@
|
||||
import prisma from 'wasp/server/dbClient.js'
|
||||
import {
|
||||
updateTask as updateTaskUser,
|
||||
createTask as createTaskUser,
|
||||
deleteTasks as deleteTasksUser,
|
||||
} from 'wasp/ext-src/actions.js'
|
||||
|
||||
export type UpdateTask = typeof updateTask
|
||||
|
||||
export const updateTask = async (args, context) => {
|
||||
return (updateTaskUser as any)(args, {
|
||||
...context,
|
||||
entities: {
|
||||
Task: prisma.task,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type CreateTask = typeof createTask
|
||||
|
||||
export const createTask = async (args, context) => {
|
||||
return (createTaskUser as any)(args, {
|
||||
...context,
|
||||
entities: {
|
||||
Task: prisma.task,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type DeleteTasks = typeof deleteTasks
|
||||
|
||||
export const deleteTasks = async (args, context) => {
|
||||
return (deleteTasksUser as any)(args, {
|
||||
...context,
|
||||
entities: {
|
||||
Task: prisma.task,
|
||||
},
|
||||
})
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import {
|
||||
type _Task,
|
||||
type AuthenticatedAction,
|
||||
type Payload,
|
||||
} from '../_types'
|
||||
|
||||
export type CreateTask<Input extends Payload = never, Output extends Payload = Payload> =
|
||||
AuthenticatedAction<
|
||||
[
|
||||
_Task,
|
||||
],
|
||||
Input,
|
||||
Output
|
||||
>
|
||||
|
||||
export type UpdateTask<Input extends Payload = never, Output extends Payload = Payload> =
|
||||
AuthenticatedAction<
|
||||
[
|
||||
_Task,
|
||||
],
|
||||
Input,
|
||||
Output
|
||||
>
|
||||
|
||||
export type DeleteTasks<Input extends Payload = never, Output extends Payload = Payload> =
|
||||
AuthenticatedAction<
|
||||
[
|
||||
_Task,
|
||||
],
|
||||
Input,
|
||||
Output
|
||||
>
|
||||
|
||||
|
12
waspc/data/Generator/templates/sdk/wasp/server/dbClient.ts
Normal file
12
waspc/data/Generator/templates/sdk/wasp/server/dbClient.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import Prisma from '@prisma/client'
|
||||
|
||||
|
||||
const createDbClient = () => {
|
||||
const prismaClient = new Prisma.PrismaClient()
|
||||
|
||||
return prismaClient
|
||||
}
|
||||
|
||||
const dbClient = createDbClient()
|
||||
|
||||
export default dbClient
|
@ -0,0 +1,13 @@
|
||||
import prisma from 'wasp/server/dbClient.js'
|
||||
import { getTasks as getTasksUser } from 'wasp/ext-src/queries.js'
|
||||
|
||||
export type GetTasks = typeof getTasksUser
|
||||
|
||||
export const getTasks = async (args, context) => {
|
||||
return (getTasksUser as any)(args, {
|
||||
...context,
|
||||
entities: {
|
||||
Task: prisma.task,
|
||||
},
|
||||
})
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { type _Task, type AuthenticatedQuery, type Payload } from "../_types";
|
||||
|
||||
export type GetTasks<
|
||||
Input extends Payload = never,
|
||||
Output extends Payload = Payload
|
||||
> = AuthenticatedQuery<[_Task], Input, Output>;
|
67
waspc/data/Generator/templates/sdk/wasp/server/utils.ts
Normal file
67
waspc/data/Generator/templates/sdk/wasp/server/utils.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
|
||||
import { readdir } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { type SanitizedUser } from './_types/index.js'
|
||||
|
||||
type RequestWithExtraFields = Request & {
|
||||
user?: SanitizedUser;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator for async express middleware that handles promise rejections.
|
||||
* @param {Func} middleware - Express middleware function.
|
||||
* @returns Express middleware that is exactly the same as the given middleware but,
|
||||
* if given middleware returns promise, reject of that promise will be correctly handled,
|
||||
* meaning that error will be forwarded to next().
|
||||
*/
|
||||
export const handleRejection = (
|
||||
middleware: (
|
||||
req: RequestWithExtraFields,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => any
|
||||
) =>
|
||||
async (req: RequestWithExtraFields, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await middleware(req, res, next)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const sleep = (ms: number): Promise<unknown> => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export function getDirPathFromFileUrl(fileUrl: string): string {
|
||||
return fileURLToPath(dirname(fileUrl))
|
||||
}
|
||||
|
||||
export async function importJsFilesFromDir(
|
||||
pathToDir: string,
|
||||
whitelistedFileNames: string[] | null = null
|
||||
): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
readdir(pathToDir, async (err, files) => {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
}
|
||||
const importPromises = files
|
||||
.filter((file) => file.endsWith('.js') && isWhitelistedFileName(file))
|
||||
.map((file) => import(`${pathToDir}/${file}`))
|
||||
resolve(Promise.all(importPromises))
|
||||
})
|
||||
})
|
||||
|
||||
function isWhitelistedFileName(fileName: string) {
|
||||
// No whitelist means all files are whitelisted
|
||||
if (!Array.isArray(whitelistedFileNames)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName)
|
||||
}
|
||||
}
|
9
waspc/data/Generator/templates/sdk/wasp/types/index.ts
Normal file
9
waspc/data/Generator/templates/sdk/wasp/types/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs).
|
||||
export enum HttpMethod {
|
||||
Get = 'GET',
|
||||
Post = 'POST',
|
||||
Put = 'PUT',
|
||||
Delete = 'DELETE',
|
||||
}
|
||||
|
||||
export type Route = { method: HttpMethod; path: string }
|
31
waspc/data/Generator/templates/sdk/wasp/universal/types.ts
Normal file
31
waspc/data/Generator/templates/sdk/wasp/universal/types.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// This is a helper type used exclusively for DX purposes. It's a No-op for the
|
||||
// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
|
||||
// type constructors) to make it more readable for the user.
|
||||
//
|
||||
// It expands this SO answer to functions: https://stackoverflow.com/a/57683652
|
||||
export type Expand<T> = T extends (...args: infer A) => infer R
|
||||
? (...args: A) => R
|
||||
: T extends infer O
|
||||
? { [K in keyof O]: O[K] }
|
||||
: never
|
||||
|
||||
// TypeScript's native Awaited type exhibits strange behavior in VS Code (see
|
||||
// https://github.com/wasp-lang/wasp/pull/1090#discussion_r1159687537 for
|
||||
// details). Until it's fixed, we're using our own type for this.
|
||||
//
|
||||
// TODO: investigate further. This most likely has something to do with an
|
||||
// unsatisfied 'extends' constraints. A mismatch is probably happening with
|
||||
// function parameter types and/or return types (check '_ReturnType' below for
|
||||
// more).
|
||||
export type _Awaited<T> = T extends Promise<infer V>
|
||||
? _Awaited<V>
|
||||
: T
|
||||
|
||||
// TypeScript's native ReturnType does not work for functions of type '(...args:
|
||||
// never[]) => unknown' (and that's what operations currently use).
|
||||
//
|
||||
// TODO: investigate how to properly specify the 'extends' constraint for function
|
||||
// type (i.e., any vs never and unknown) and stick with that. Take DX into
|
||||
// consideration.
|
||||
export type _ReturnType<T extends (...args: never[]) => unknown> =
|
||||
T extends (...args: never[]) => infer R ? R : never
|
3
waspc/data/Generator/templates/sdk/wasp/universal/url.ts
Normal file
3
waspc/data/Generator/templates/sdk/wasp/universal/url.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function stripTrailingSlash(url?: string): string | undefined {
|
||||
return url?.replace(/\/$/, "");
|
||||
}
|
@ -4,7 +4,9 @@
|
||||
},
|
||||
"watch": [
|
||||
"src/",
|
||||
"../../../src/",
|
||||
".env"
|
||||
],
|
||||
"comment-filip": "We now have to watch ../../../src/ because we're importing client files directly",
|
||||
"ext": "ts,mts,js,mjs,json"
|
||||
}
|
||||
|
@ -4,9 +4,10 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"comment-filip": "The server.js location changed because we have now included client source files above .wasp/out/server/src.",
|
||||
"scripts": {
|
||||
"build": "npx tsc",
|
||||
"start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/server.js",
|
||||
"start": "npm run validate-env && NODE_PATH=dist node -r dotenv/config dist/.wasp/out/server/src/server.js",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"watch": "nodemon --exec 'npm run build-and-start || exit 1'",
|
||||
"validate-env": "node -r dotenv/config ./scripts/validate-env.mjs",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import express from 'express'
|
||||
|
||||
import HttpError from './core/HttpError.js'
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
import indexRouter from './routes/index.js'
|
||||
|
||||
// TODO: Consider extracting most of this logic into createApp(routes, path) function so that
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
import { ensureValidEmail } from "../../validation.js";
|
||||
import type { EmailFromField } from '../../../email/core/types.js';
|
||||
import { GetPasswordResetEmailContentFn } from './types.js';
|
||||
import HttpError from '../../../core/HttpError.js';
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
|
||||
export function getRequestPasswordResetRoute({
|
||||
fromField,
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
} from "../../utils.js";
|
||||
import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js";
|
||||
import { tokenVerificationErrors } from "./types.js";
|
||||
import HttpError from '../../../core/HttpError.js';
|
||||
import HttpError from 'wasp/core/HttpError';
|
||||
|
||||
export async function resetPassword(
|
||||
req: Request<{ token: string; password: string; }>,
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
import { ensureValidEmail, ensureValidPassword, ensurePasswordIsPresent } from "../../validation.js";
|
||||
import { GetVerificationEmailContentFn } from './types.js';
|
||||
import { validateAndGetUserFields } from '../../utils.js'
|
||||
import HttpError from '../../../core/HttpError.js';
|
||||
import HttpError from 'wasp/core/HttpError';
|
||||
import { type UserSignupFields } from '../types.js';
|
||||
|
||||
export function getSignupRoute({
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
deserializeAndSanitizeProviderData,
|
||||
} from '../../utils.js';
|
||||
import { tokenVerificationErrors } from './types.js';
|
||||
import HttpError from '../../../core/HttpError.js';
|
||||
import HttpError from 'wasp/core/HttpError';
|
||||
|
||||
|
||||
export async function verifyEmail(
|
||||
|
@ -1,8 +1,8 @@
|
||||
{{={= =}=}}
|
||||
import { hashPassword } from './password.js'
|
||||
import { verify } from './jwt.js'
|
||||
import AuthError from '../core/AuthError.js'
|
||||
import HttpError from '../core/HttpError.js'
|
||||
import AuthError from 'wasp/core/AuthError'
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
import prisma from '../dbClient.js'
|
||||
import { sleep } from '../utils.js'
|
||||
import {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import HttpError from '../core/HttpError.js';
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
|
||||
export const PASSWORD_FIELD = 'password';
|
||||
const USERNAME_FIELD = 'username';
|
||||
|
@ -3,6 +3,15 @@
|
||||
"extends": "@tsconfig/node{= majorNodeVersion =}/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Overriding this until we implement more complete TypeScript support.
|
||||
// Filip: begin client file hacks
|
||||
// We need this to make server work with copied client files (we copy everything)
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
// Filip: end client file hacks
|
||||
"strict": false,
|
||||
// Overriding this because we want to use top-level await
|
||||
"module": "esnext",
|
||||
@ -11,12 +20,13 @@
|
||||
"sourceMap": true,
|
||||
// The remaining settings should match the extended nodeXY/tsconfig.json, but I kept
|
||||
// them here to be explicit.
|
||||
|
||||
// Enable default imports in TypeScript.
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { defineUserSignupFields } from '@wasp/auth/index.js'
|
||||
import { defineUserSignupFields } from 'wasp/auth/index.js'
|
||||
|
||||
export const userSignupFields = defineUserSignupFields({
|
||||
address: (data) => data.address,
|
||||
|
4
waspc/examples/todo-typescript/.gitignore
vendored
Normal file
4
waspc/examples/todo-typescript/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/.wasp/
|
||||
/.env.server
|
||||
/.env.client
|
||||
/node_modules/
|
1
waspc/examples/todo-typescript/.wasproot
Normal file
1
waspc/examples/todo-typescript/.wasproot
Normal file
@ -0,0 +1 @@
|
||||
File marking the root of Wasp project.
|
75
waspc/examples/todo-typescript/main.wasp
Normal file
75
waspc/examples/todo-typescript/main.wasp
Normal file
@ -0,0 +1,75 @@
|
||||
app TodoTypescript {
|
||||
wasp: {
|
||||
version: "^0.12.0"
|
||||
},
|
||||
title: "ToDo TypeScript",
|
||||
|
||||
auth: {
|
||||
userEntity: User,
|
||||
methods: {
|
||||
usernameAndPassword: {}, // this is a very naive implementation, use 'email' in production instead
|
||||
//google: {}, //https://wasp-lang.dev/docs/integrations/google
|
||||
//gitHub: {}, //https://wasp-lang.dev/docs/integrations/github
|
||||
//email: {} //https://wasp-lang.dev/docs/guides/email-auth
|
||||
},
|
||||
onAuthFailedRedirectTo: "/login",
|
||||
}
|
||||
}
|
||||
|
||||
// Use Prisma Schema Language (PSL) to define our entities: https://www.prisma.io/docs/concepts/components/prisma-schema
|
||||
// Run `wasp db migrate-dev` in the CLI to create the database tables
|
||||
// Then run `wasp db studio` to open Prisma Studio and view your db models
|
||||
entity User {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
tasks Task[]
|
||||
psl=}
|
||||
|
||||
entity Task {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
description String
|
||||
isDone Boolean @default(false)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
psl=}
|
||||
|
||||
route RootRoute { path: "/", to: MainPage }
|
||||
page MainPage {
|
||||
authRequired: true,
|
||||
// todo(filip): LSP features are broken beucase I haven't yet updated LSP to the new structure.
|
||||
component: import { MainPage } from "@src/MainPage.tsx"
|
||||
}
|
||||
|
||||
route LoginRoute { path: "/login", to: LoginPage }
|
||||
page LoginPage {
|
||||
component: import { LoginPage } from "@src/user/LoginPage.tsx"
|
||||
}
|
||||
|
||||
route SignupRoute { path: "/signup", to: SignupPage }
|
||||
page SignupPage {
|
||||
component: import { SignupPage } from "@src/user/SignupPage.tsx"
|
||||
}
|
||||
|
||||
query getTasks {
|
||||
// We specify the JS implementation of our query (which is an async JS function)
|
||||
// Even if you use TS and have a queries.ts file, you will still need to import it using the .js extension.
|
||||
// see here for more info: https://wasp-lang.dev/docs/tutorials/todo-app/03-listing-tasks#wasp-declaration
|
||||
fn: import { getTasks } from "@src/task/queries.js",
|
||||
// We tell Wasp that this query is doing something with the `Task` entity. With that, Wasp will
|
||||
// automatically refresh the results of this query when tasks change.
|
||||
entities: [Task]
|
||||
}
|
||||
|
||||
action createTask {
|
||||
fn: import { createTask } from "@src/task/actions.js",
|
||||
entities: [Task]
|
||||
}
|
||||
|
||||
action updateTask {
|
||||
fn: import { updateTask } from "@src/task/actions.js",
|
||||
entities: [Task]
|
||||
}
|
||||
|
||||
action deleteTasks {
|
||||
fn: import { deleteTasks } from "@src/task/actions.js",
|
||||
entities: [Task],
|
||||
}
|
6
waspc/examples/todo-typescript/migrate
Executable file
6
waspc/examples/todo-typescript/migrate
Executable file
@ -0,0 +1,6 @@
|
||||
rsync -a .wasp/out/web-app/node_modules/ node_modules/
|
||||
rsync -a .wasp/out/server/node_modules/ node_modules/
|
||||
# rsync -a node_modules_wasp/ node_modules
|
||||
cabal run wasp-cli db migrate-dev
|
||||
find .wasp/out/server/node_modules -mindepth 1 -type d | grep -Eiv 'prisma|\.bin' | xargs rm -r 2> /dev/null
|
||||
rm -r .wasp/out/web-app/node_modules
|
@ -30,5 +30,19 @@ CREATE TABLE "AuthIdentity" (
|
||||
CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
12
waspc/examples/todo-typescript/package.json
Normal file
12
waspc/examples/todo-typescript/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "prototype",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^4.16.2",
|
||||
"react": "18.2.0",
|
||||
"wasp": "file:.wasp/out/sdk/wasp"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.37",
|
||||
"prisma": "^4.16.2"
|
||||
}
|
||||
}
|
3
waspc/examples/todo-typescript/src/.waspignore
Normal file
3
waspc/examples/todo-typescript/src/.waspignore
Normal file
@ -0,0 +1,3 @@
|
||||
# Ignore editor tmp files
|
||||
**/*~
|
||||
**/#*#
|
73
waspc/examples/todo-typescript/src/Main.css
Normal file
73
waspc/examples/todo-typescript/src/Main.css
Normal file
@ -0,0 +1,73 @@
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding: 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
main p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.logout {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem;
|
||||
background: #efefef;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.auth-form h2 {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 300px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tasklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: 300px;
|
||||
margin-top: 1rem;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
107
waspc/examples/todo-typescript/src/MainPage.tsx
Normal file
107
waspc/examples/todo-typescript/src/MainPage.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import './Main.css'
|
||||
import React, { useEffect, FormEventHandler, FormEvent } from 'react'
|
||||
import logout from 'wasp/auth/logout'
|
||||
import { useQuery, useAction } from 'wasp/rpc' // Wasp uses a thin wrapper around react-query
|
||||
import { getTasks } from 'wasp/rpc/queries'
|
||||
import { createTask, updateTask, deleteTasks } from 'wasp/rpc/actions'
|
||||
import waspLogo from './waspLogo.png'
|
||||
import type { Task } from 'wasp/entities'
|
||||
import type { User } from 'wasp/auth/types'
|
||||
import { getUsername } from 'wasp/auth/user'
|
||||
|
||||
export const MainPage = ({ user }: { user: User }) => {
|
||||
const { data: tasks, isLoading, error } = useQuery(getTasks)
|
||||
|
||||
if (isLoading) return 'Loading...'
|
||||
if (error) return 'Error: ' + error
|
||||
|
||||
const completed = tasks?.filter((task) => task.isDone).map((task) => task.id)
|
||||
|
||||
return (
|
||||
<main>
|
||||
<img src={waspLogo} alt="wasp logo" />
|
||||
{user && (
|
||||
<h1>
|
||||
{getUsername(user)}
|
||||
{`'s tasks :)`}
|
||||
</h1>
|
||||
)}
|
||||
<NewTaskForm />
|
||||
{tasks && <TasksList tasks={tasks} />}
|
||||
<div className="buttons">
|
||||
<button
|
||||
className="logout"
|
||||
onClick={() => void deleteTasks(completed ?? [])}
|
||||
>
|
||||
Delete completed
|
||||
</button>
|
||||
<button className="logout" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Todo({ id, isDone, description }: Task) {
|
||||
const handleIsDoneChange: FormEventHandler<HTMLInputElement> = async (
|
||||
event
|
||||
) => {
|
||||
try {
|
||||
await updateTask({
|
||||
id,
|
||||
isDone: event.currentTarget.checked,
|
||||
})
|
||||
} catch (err: any) {
|
||||
window.alert('Error while updating task ' + err?.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<span className="todo-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id.toString()}
|
||||
checked={isDone}
|
||||
onChange={handleIsDoneChange}
|
||||
/>
|
||||
<span>{description}</span>
|
||||
<button onClick={() => void deleteTasks([id])}>Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function TasksList({ tasks }: { tasks: Task[] }) {
|
||||
if (tasks.length === 0) return <p>No tasks yet.</p>
|
||||
return (
|
||||
<ol className="tasklist">
|
||||
{tasks.map((task, idx) => (
|
||||
<Todo {...task} key={idx} />
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function NewTaskForm() {
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const description = event.currentTarget.description.value
|
||||
console.log(description)
|
||||
event.currentTarget.reset()
|
||||
await createTask({ description })
|
||||
} catch (err: any) {
|
||||
window.alert('Error: ' + err?.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input name="description" type="text" defaultValue="" />
|
||||
<input type="submit" value="Create task" />
|
||||
</form>
|
||||
)
|
||||
}
|
56
waspc/examples/todo-typescript/src/task/actions.ts
Normal file
56
waspc/examples/todo-typescript/src/task/actions.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
import type {
|
||||
CreateTask,
|
||||
UpdateTask,
|
||||
DeleteTasks,
|
||||
} from 'wasp/server/actions/types'
|
||||
import type { Task } from 'wasp/entities'
|
||||
|
||||
type CreateArgs = Pick<Task, 'description'>
|
||||
|
||||
export const createTask: CreateTask<CreateArgs, Task> = async (
|
||||
{ description },
|
||||
context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
return context.entities.Task.create({
|
||||
data: {
|
||||
description,
|
||||
user: { connect: { id: context.user.id } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateArgs = Pick<Task, 'id' | 'isDone'>
|
||||
|
||||
export const updateTask: UpdateTask<UpdateArgs> = async (
|
||||
{ id, isDone },
|
||||
context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
return context.entities.Task.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: { isDone },
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteTasks: DeleteTasks<Task['id'][]> = async (
|
||||
idsToDelete,
|
||||
context
|
||||
) => {
|
||||
return context.entities.Task.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: idsToDelete,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
15
waspc/examples/todo-typescript/src/task/queries.ts
Normal file
15
waspc/examples/todo-typescript/src/task/queries.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import HttpError from 'wasp/core/HttpError'
|
||||
import type { GetTasks } from 'wasp/server/queries/types'
|
||||
import type { Task } from 'wasp/entities'
|
||||
|
||||
//Using TypeScript's new 'satisfies' keyword, it will infer the types of the arguments and return value
|
||||
export const getTasks = ((_args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
return context.entities.Task.findMany({
|
||||
where: { user: { id: context.user.id } },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
}) satisfies GetTasks<void, Task[]>
|
17
waspc/examples/todo-typescript/src/user/LoginPage.tsx
Normal file
17
waspc/examples/todo-typescript/src/user/LoginPage.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { LoginForm } from 'wasp/auth/forms/Login'
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<main>
|
||||
{/** Wasp has built-in auth forms & flows, which you can customize or opt-out of, if you wish :)
|
||||
* https://wasp-lang.dev/docs/guides/auth-ui
|
||||
*/}
|
||||
<LoginForm />
|
||||
<br />
|
||||
<span>
|
||||
I don't have an account yet (<Link to="/signup">go to signup</Link>).
|
||||
</span>
|
||||
</main>
|
||||
)
|
||||
}
|
17
waspc/examples/todo-typescript/src/user/SignupPage.tsx
Normal file
17
waspc/examples/todo-typescript/src/user/SignupPage.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { SignupForm } from 'wasp/auth/forms/Signup'
|
||||
|
||||
export function SignupPage() {
|
||||
return (
|
||||
<main>
|
||||
{/** Wasp has built-in auth forms & flows, which you can customize or opt-out of, if you wish :)
|
||||
* https://wasp-lang.dev/docs/guides/auth-ui
|
||||
*/}
|
||||
<SignupForm />
|
||||
<br />
|
||||
<span>
|
||||
I already have an account (<Link to="/login">go to login</Link>).
|
||||
</span>
|
||||
</main>
|
||||
)
|
||||
}
|
1
waspc/examples/todo-typescript/src/vite-env.d.ts
vendored
Normal file
1
waspc/examples/todo-typescript/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
BIN
waspc/examples/todo-typescript/src/waspLogo.png
Normal file
BIN
waspc/examples/todo-typescript/src/waspLogo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
28
waspc/examples/todo-typescript/tsconfig.json
Normal file
28
waspc/examples/todo-typescript/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
// =============================== IMPORTANT =================================
|
||||
//
|
||||
// This file is only used for Wasp IDE support. You can change it to configure
|
||||
// your IDE checks, but none of these options will affect the TypeScript
|
||||
// compiler. Proper TS compiler configuration in Wasp is coming soon :)
|
||||
{
|
||||
"compilerOptions": {
|
||||
// JSX support
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
// Allow default imports.
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
// Since this TS config is used only for IDE support and not for
|
||||
// compilation, the following directory doesn't exist. We need to specify
|
||||
// it to prevent this error:
|
||||
// https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
|
||||
"outDir": "phantom"
|
||||
},
|
||||
"exclude": [
|
||||
"phantom"
|
||||
],
|
||||
}
|
@ -169,13 +169,21 @@ extImport = evaluation' . withCtx $ \ctx -> \case
|
||||
Nothing ->
|
||||
mkParseError
|
||||
ctx
|
||||
$ "Path in external import must start with \"" ++ serverPrefix ++ "\"" ++ " or \"" ++ clientPrefix ++ "\"!"
|
||||
$ "Path in external import must start with \"" ++ extSrcPrefix ++ "\"!"
|
||||
expr -> Left $ ER.mkEvaluationError ctx $ ER.ExpectedType T.ExtImportType (TypedAST.exprType expr)
|
||||
where
|
||||
mkParseError ctx msg = Left $ ER.mkEvaluationError ctx $ ER.ParseError $ ER.EvaluationParseError msg
|
||||
stripImportPrefix importPath = stripPrefix serverPrefix importPath <|> stripPrefix clientPrefix importPath
|
||||
serverPrefix = "@server/"
|
||||
clientPrefix = "@client/"
|
||||
stripImportPrefix importPath = stripPrefix extSrcPrefix importPath
|
||||
-- Filip: We no longer want separation between client and server code
|
||||
-- todo (filip): Do we still want to know whic is which. We might (because of the reloading).
|
||||
-- For now, as we'd like (expect):
|
||||
-- - Nodemon watches all files in the user's source folder (client files
|
||||
-- included), but tsc only compiles the server files (I think because it
|
||||
-- knows that the others aren't used). I am not yet sure how it knows this.
|
||||
-- - Vite also only triggers on client files. I am not sure how it knows
|
||||
-- about the difference either.
|
||||
-- todo (filip): investigate
|
||||
extSrcPrefix = "@src/"
|
||||
|
||||
-- | An evaluation that expects a "JSON".
|
||||
json :: TypedExprEvaluation AppSpec.JSON.JSON
|
||||
|
@ -21,6 +21,7 @@ import Wasp.Generator.DbGenerator (genDb)
|
||||
import Wasp.Generator.DockerGenerator (genDockerFiles)
|
||||
import Wasp.Generator.FileDraft (FileDraft)
|
||||
import Wasp.Generator.Monad (Generator, GeneratorError, GeneratorWarning, runGenerator)
|
||||
import Wasp.Generator.SdkGenerator (genSdk)
|
||||
import Wasp.Generator.ServerGenerator (genServer)
|
||||
import Wasp.Generator.Setup (runSetup)
|
||||
import qualified Wasp.Generator.Start
|
||||
@ -54,6 +55,7 @@ genApp :: AppSpec -> Generator [FileDraft]
|
||||
genApp spec =
|
||||
genWebApp spec
|
||||
<++> genServer spec
|
||||
<++> genSdk spec
|
||||
<++> genDb spec
|
||||
<++> genDockerFiles spec
|
||||
<++> genConfigFiles spec
|
||||
|
@ -18,15 +18,10 @@ import qualified Wasp.Generator.FileDraft as FD
|
||||
import Wasp.Generator.Monad (Generator)
|
||||
|
||||
genSourceFile :: C.ExternalCodeGeneratorStrategy -> EC.File -> Generator FD.FileDraft
|
||||
genSourceFile strategy file = return $ FD.createTextFileDraft dstPath text'
|
||||
genSourceFile strategy file = return $ FD.createTextFileDraft dstPath text
|
||||
where
|
||||
filePathInSrcExtCodeDir = EC.filePathInExtCodeDir file
|
||||
|
||||
filePathInGenExtCodeDir :: Path' (Rel C.GeneratedExternalCodeDir) File'
|
||||
filePathInGenExtCodeDir = C.castRelPathFromSrcToGenExtCodeDir filePathInSrcExtCodeDir
|
||||
|
||||
text = EC.fileText file
|
||||
text' = C._resolveJsFileWaspImports strategy filePathInGenExtCodeDir text
|
||||
dstPath = C._resolveDstFilePath strategy filePathInSrcExtCodeDir
|
||||
|
||||
-- | Replaces imports that start with "@wasp/" with imports that start from the src dir of the app.
|
||||
|
@ -34,6 +34,22 @@ extImportToJsImport pathFromSrcDirToExtCodeDir pathFromImportLocationToSrcDir ex
|
||||
extImportNameToJsImportName (EI.ExtImportModule name) = JsImportModule name
|
||||
extImportNameToJsImportName (EI.ExtImportField name) = JsImportField name
|
||||
|
||||
-- filip: attempt to simplify how we generate imports. I wanted to generate a
|
||||
-- module import (e.g., '@ext-src/something') and couldn't do it
|
||||
-- jsImportToImportJsonRaw :: Maybe (FilePath, JsImportName, Maybe JsImportAlias) -> Aeson.Value
|
||||
-- jsImportToImportJsonRaw importData = maybe notDefinedValue mkTmplData importData
|
||||
-- where
|
||||
-- notDefinedValue = object ["isDefined" .= False]
|
||||
|
||||
-- mkTmplData :: (FilePath, JsImportName, Maybe JsImportAlias) -> Aeson.Value
|
||||
-- mkTmplData (importPath, importName, maybeImportAlias) =
|
||||
-- let (jsImportStmt, jsImportIdentifier) = getJsImportStmtAndIdentifierRaw importPath importName maybeImportAlias
|
||||
-- in object
|
||||
-- [ "isDefined" .= True,
|
||||
-- "importStatement" .= jsImportStmt,
|
||||
-- "importIdentifier" .= jsImportIdentifier
|
||||
-- ]
|
||||
|
||||
jsImportToImportJson :: Maybe JsImport -> Aeson.Value
|
||||
jsImportToImportJson maybeJsImport = maybe notDefinedValue mkTmplData maybeJsImport
|
||||
where
|
||||
|
90
waspc/src/Wasp/Generator/SdkGenerator.hs
Normal file
90
waspc/src/Wasp/Generator/SdkGenerator.hs
Normal file
@ -0,0 +1,90 @@
|
||||
module Wasp.Generator.SdkGenerator where
|
||||
|
||||
import Data.Aeson (object)
|
||||
import qualified Data.Aeson as Aeson
|
||||
import Data.Aeson.Types ((.=))
|
||||
import GHC.IO (unsafePerformIO)
|
||||
import StrongPath
|
||||
import Wasp.AppSpec
|
||||
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
|
||||
import Wasp.AppSpec.Valid (isAuthEnabled)
|
||||
import Wasp.Generator.Common (ProjectRootDir, prismaVersion)
|
||||
import Wasp.Generator.FileDraft (FileDraft, createCopyDirFileDraft, createTemplateFileDraft)
|
||||
import Wasp.Generator.FileDraft.CopyDirFileDraft (CopyDirFileDraftDstDirStrategy (RemoveExistingDstDir))
|
||||
import Wasp.Generator.Monad (Generator)
|
||||
import qualified Wasp.Generator.NpmDependencies as N
|
||||
import Wasp.Generator.Templates (TemplatesDir, getTemplatesDirAbsPath)
|
||||
import qualified Wasp.SemanticVersion as SV
|
||||
|
||||
genSdk :: AppSpec -> Generator [FileDraft]
|
||||
genSdk spec = sequence [genSdkModules, genPackageJson spec]
|
||||
|
||||
data SdkRootDir
|
||||
|
||||
data SdkTemplatesDir
|
||||
|
||||
genSdkModules :: Generator FileDraft
|
||||
genSdkModules =
|
||||
return $
|
||||
createCopyDirFileDraft
|
||||
RemoveExistingDstDir
|
||||
sdkRootDirInProjectRootDir
|
||||
(unsafePerformIO getTemplatesDirAbsPath </> sdkTemplatesDirInTemplatesDir </> [reldir|wasp|])
|
||||
|
||||
genPackageJson :: AppSpec -> Generator FileDraft
|
||||
genPackageJson spec =
|
||||
return $
|
||||
mkTmplFdWithDstAndData
|
||||
[relfile|package.json|]
|
||||
[relfile|package.json|]
|
||||
( Just $
|
||||
object
|
||||
[ "depsChunk" .= N.getDependenciesPackageJsonEntry npmDepsForSdk,
|
||||
"devDepsChunk" .= N.getDevDependenciesPackageJsonEntry npmDepsForSdk
|
||||
]
|
||||
)
|
||||
where
|
||||
npmDepsForSdk =
|
||||
N.NpmDepsForPackage
|
||||
{ N.dependencies =
|
||||
AS.Dependency.fromList
|
||||
[ ("@prisma/client", show prismaVersion),
|
||||
("prisma", show prismaVersion),
|
||||
("@tanstack/react-query", "^4.29.0"),
|
||||
("axios", "^1.4.0"),
|
||||
("express", "~4.18.1"),
|
||||
("jsonwebtoken", "^8.5.1"),
|
||||
("mitt", "3.0.0"),
|
||||
("react", "^18.2.0"),
|
||||
("react-router-dom", "^5.3.3"),
|
||||
("react-hook-form", "^7.45.4"),
|
||||
("secure-password", "^4.0.0"),
|
||||
("superjson", "^1.12.2"),
|
||||
("@types/express-serve-static-core", "^4.17.13")
|
||||
]
|
||||
++ depsRequiredForAuth spec,
|
||||
N.devDependencies = AS.Dependency.fromList []
|
||||
}
|
||||
|
||||
depsRequiredForAuth :: AppSpec -> [AS.Dependency.Dependency]
|
||||
depsRequiredForAuth spec =
|
||||
[AS.Dependency.make ("@stitches/react", show versionRange) | isAuthEnabled spec]
|
||||
where
|
||||
versionRange = SV.Range [SV.backwardsCompatibleWith (SV.Version 1 2 8)]
|
||||
|
||||
mkTmplFdWithDstAndData ::
|
||||
Path' (Rel SdkTemplatesDir) File' ->
|
||||
Path' (Rel SdkRootDir) File' ->
|
||||
Maybe Aeson.Value ->
|
||||
FileDraft
|
||||
mkTmplFdWithDstAndData relSrcPath relDstPath tmplData =
|
||||
createTemplateFileDraft
|
||||
(sdkRootDirInProjectRootDir </> relDstPath)
|
||||
(sdkTemplatesDirInTemplatesDir </> relSrcPath)
|
||||
tmplData
|
||||
|
||||
sdkRootDirInProjectRootDir :: Path' (Rel ProjectRootDir) (Dir SdkRootDir)
|
||||
sdkRootDirInProjectRootDir = [reldir|sdk/wasp|]
|
||||
|
||||
sdkTemplatesDirInTemplatesDir :: Path' (Rel TemplatesDir) (Dir SdkTemplatesDir)
|
||||
sdkTemplatesDirInTemplatesDir = [reldir|sdk|]
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user