Create restructuring prototype (#1584)

This commit is contained in:
Filip Sodić 2024-01-29 14:00:20 +01:00 committed by GitHub
parent b227daf5e3
commit 6f0714ad23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 3422 additions and 87 deletions

View File

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

View File

@ -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");

View File

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

View File

@ -0,0 +1,10 @@
{
"name": "prototype",
"dependencies": {
"wasp": "file:.wasp/out/sdk/wasp",
"react": "18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.37"
}
}

View File

@ -7,7 +7,7 @@ import router from './router'
import {
initializeQueryClient,
queryClientInitialized,
} from './queryClient'
} from 'wasp/rpc/queryClient'
{=# setupFn.isDefined =}
{=& setupFn.importStatement =}

View File

@ -12,7 +12,7 @@ import type {
{=/ rootComponent.isDefined =}
{=# isAuthEnabled =}
import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage"
import createAuthRequiredPage from "wasp/auth/pages/createAuthRequiredPage"
{=/ isAuthEnabled =}
{=# pagesToImport =}

View File

@ -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"
}
]
}

View File

@ -14,6 +14,9 @@ const _waspUserProvidedConfig = {};
const defaultViteConfig = {
base: "{= baseDir =}",
plugins: [react()],
optimizeDeps: {
exclude: ['wasp']
},
server: {
port: {= defaultClientPort =},
host: "0.0.0.0",

View 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")
]
}

View 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 =}
}

View 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>();

View 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

View 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;

View 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}
/>
)
}

View File

@ -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}
/>
)
}

View File

@ -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',
})

View File

@ -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',
})

View File

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

View File

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

View 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

View 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()
}

View 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)

View 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)
}
}

View 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()
}
}

View 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']
};
}
}

View File

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

View 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.')
}
}

View File

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

View 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);
}

View 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)
}
}

View File

@ -0,0 +1,2 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/'

View 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
}

View 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
);
}

View 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 })
}

View 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)
}

View 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

View 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

View File

@ -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'
}
}
})

View 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.')
}
}

View 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

View 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,
},
},
})
}

View 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[]>

View 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}` }
}

View File

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

View File

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

View 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

View 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
}

View 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',
])

View 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];
}

View 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

View 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)
}

View File

@ -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'

View 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);
}

View 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'

View File

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

View File

@ -0,0 +1,22 @@
// Wasp internally uses the types defined in this file for typing entity maps in
// operation contexts.
//
// We must explicitly tag all entities with their name to avoid issues with
// structural typing. See https://github.com/wasp-lang/wasp/pull/982 for details.
import {
type Entity,
type EntityName,
type User,
type 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 }

View File

@ -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,
},
})
}

View File

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

View 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

View File

@ -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,
},
})
}

View File

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

View 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)
}
}

View 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 }

View 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

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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,

View File

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

View File

@ -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({

View File

@ -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(

View File

@ -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 {

View File

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

View File

@ -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"
]
}

View File

@ -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,

View File

@ -0,0 +1,4 @@
/.wasp/
/.env.server
/.env.client
/node_modules/

View File

@ -0,0 +1 @@
File marking the root of Wasp project.

View 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],
}

View 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

View File

@ -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");

View 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"
}
}

View File

@ -0,0 +1,3 @@
# Ignore editor tmp files
**/*~
**/#*#

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

View 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>
)
}

View 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,
},
},
})
}

View 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[]>

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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"
],
}

View File

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

View File

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

View File

@ -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.

View File

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

View 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