Signup Customisation [RFC] (#1395)

This commit is contained in:
Mihovil Ilakovac 2023-09-12 13:19:47 +02:00 committed by GitHub
parent cb88dfc618
commit 99c9021f82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 1469 additions and 196 deletions

View File

@ -2,6 +2,72 @@
## 0.11.4 ## 0.11.4
### 🎉 [New Feature] Signup Fields Customization
We added an API for extending the default signup form with custom fields. This allows you to add fields like `age`, `address`, etc. to your signup form.
You first need to define the `auth.signup.additionalFields` property in your `.wasp` file:
```wasp
app crudTesting {
// ...
auth: {
userEntity: User,
methods: {
usernameAndPassword: {},
},
onAuthFailedRedirectTo: "/login",
signup: {
additionalFields: import { fields } from "@server/auth.js",
},
},
}
```
Then, you need to define the `fields` object in your `auth.js` file:
```js
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
export const fields = defineAdditionalSignupFields({
address: (data) => {
// Validate the address field
if (typeof data.address !== 'string') {
throw new Error('Address is required.')
}
if (data.address.length < 10) {
throw new Error('Address must be at least 10 characters long.')
}
// Return the address field
return data.address
},
})
```
Finally, you can extend the `SignupForm` component on the client:
```jsx
import { SignupForm } from "@wasp/auth/forms/Signup";
export const SignupPage = () => {
return (
<div className="container">
<main>
<h1>Signup</h1>
<SignupForm
additionalFields={[
{
name: "address",
label: "Address",
type: "input",
validations: {
required: "Address is required",
},
},
]}
/>
</main>
</div>
);
};
```
### 🎉 [New Feature] Support for PostgreSQL Extensions ### 🎉 [New Feature] Support for PostgreSQL Extensions
Wasp now supports PostgreSQL extensions! You can enable them in your `main.wasp` file: Wasp now supports PostgreSQL extensions! You can enable them in your `main.wasp` file:

View File

@ -7,6 +7,7 @@ import {
type State, type State,
type CustomizationOptions, type CustomizationOptions,
type ErrorMessage, type ErrorMessage,
type AdditionalSignupFields,
} from './types' } from './types'
import { LoginSignupForm } from './internal/common/LoginSignupForm' import { LoginSignupForm } from './internal/common/LoginSignupForm'
import { MessageError, MessageSuccess } from './internal/Message' import { MessageError, MessageSuccess } from './internal/Message'
@ -39,9 +40,11 @@ export const AuthContext = createContext({
setSuccessMessage: (successMessage: string | null) => {}, setSuccessMessage: (successMessage: string | null) => {},
}) })
function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: { function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: {
state: State; state: State;
} & CustomizationOptions) { } & CustomizationOptions & {
additionalSignupFields?: AdditionalSignupFields;
}) {
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null); const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -82,6 +85,7 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
<LoginSignupForm <LoginSignupForm
state={state} state={state}
socialButtonsDirection={socialButtonsDirection} socialButtonsDirection={socialButtonsDirection}
additionalSignupFields={additionalSignupFields}
/> />
)} )}
{=# isEmailAuthEnabled =} {=# isEmailAuthEnabled =}

View File

@ -1,17 +1,23 @@
import Auth from './Auth' import Auth from './Auth'
import { type CustomizationOptions, State } from './types' import {
type CustomizationOptions,
type AdditionalSignupFields,
State,
} from './types'
export function SignupForm({ export function SignupForm({
appearance, appearance,
logo, logo,
socialLayout, socialLayout,
}: CustomizationOptions) { additionalFields,
}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) {
return ( return (
<Auth <Auth
appearance={appearance} appearance={appearance}
logo={logo} logo={logo}
socialLayout={socialLayout} socialLayout={socialLayout}
state={State.Signup} state={State.Signup}
additionalSignupFields={additionalFields}
/> />
) )
} }

View File

@ -14,9 +14,10 @@ export const FormLabel = styled('label', {
display: 'block', display: 'block',
fontSize: '$sm', fontSize: '$sm',
fontWeight: '500', fontWeight: '500',
marginBottom: '0.5rem',
}) })
export const FormInput = styled('input', { const commonInputStyles = {
display: 'block', display: 'block',
lineHeight: '1.5rem', lineHeight: '1.5rem',
fontSize: '$sm', fontSize: '$sm',
@ -44,7 +45,18 @@ export const FormInput = styled('input', {
paddingBottom: '0.375rem', paddingBottom: '0.375rem',
paddingLeft: '0.75rem', paddingLeft: '0.75rem',
paddingRight: '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', marginTop: '0.5rem',
}) })

View File

@ -1,10 +1,23 @@
{{={= =}=}} {{={= =}=}}
import { useContext, type FormEvent } from 'react' import { useContext } from 'react'
import { styled } from '../../../../stitches.config' import { useForm, UseFormReturn } from 'react-hook-form'
import config from '../../../../config.js'
import { AuthContext } from '../../Auth' import { AuthContext } from '../../Auth'
import { Form, FormInput, FormItemGroup, FormLabel, SubmitButton } from '../Form' import {
Form,
FormInput,
FormItemGroup,
FormLabel,
FormError,
FormTextarea,
SubmitButton,
} from '../Form'
import type {
AdditionalSignupFields,
AdditionalSignupField,
AdditionalSignupFieldRenderFn,
FormState,
} from '../../types'
{=# isSocialAuthEnabled =} {=# isSocialAuthEnabled =}
import * as SocialIcons from '../social/SocialIcons' import * as SocialIcons from '../social/SocialIcons'
import { SocialButton } from '../social/SocialButton' import { SocialButton } from '../social/SocialButton'
@ -97,12 +110,23 @@ const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}` const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
{=/ isGitHubAuthEnabled =} {=/ isGitHubAuthEnabled =}
{=!
// Since we allow users to add additional fields to the signup form, we don't
// know the exact shape of the form values. We are assuming that the form values
// will be a flat object with string values.
=}
export type LoginSignupFormFields = {
[key: string]: string;
}
export const LoginSignupForm = ({ export const LoginSignupForm = ({
state, state,
socialButtonsDirection = 'horizontal', socialButtonsDirection = 'horizontal',
additionalSignupFields,
}: { }: {
state: 'login' | 'signup', state: 'login' | 'signup'
socialButtonsDirection?: 'horizontal' | 'vertical'; socialButtonsDirection?: 'horizontal' | 'vertical'
additionalSignupFields?: AdditionalSignupFields
}) => { }) => {
const { const {
isLoading, isLoading,
@ -110,16 +134,19 @@ export const LoginSignupForm = ({
setSuccessMessage, setSuccessMessage,
setIsLoading, setIsLoading,
} = useContext(AuthContext) } = useContext(AuthContext)
const cta = state === 'login' ? 'Log in' : 'Sign up'; const isLogin = state === 'login'
const cta = isLogin ? 'Log in' : 'Sign up';
{=# isAnyPasswordBasedAuthEnabled =} {=# isAnyPasswordBasedAuthEnabled =}
const history = useHistory(); const history = useHistory();
const onErrorHandler = (error) => { const onErrorHandler = (error) => {
setErrorMessage({ title: error.message, description: error.data?.data?.message }) setErrorMessage({ title: error.message, description: error.data?.data?.message })
}; };
{=/ isAnyPasswordBasedAuthEnabled =} {=/ isAnyPasswordBasedAuthEnabled =}
const hookForm = useForm<LoginSignupFormFields>()
const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
{=# isUsernameAndPasswordAuthEnabled =} {=# isUsernameAndPasswordAuthEnabled =}
const { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal } = useUsernameAndPassword({ const { handleSubmit } = useUsernameAndPassword({
isLogin: state === 'login', isLogin,
onError: onErrorHandler, onError: onErrorHandler,
onSuccess() { onSuccess() {
history.push('{= onAuthSucceededRedirectTo =}') history.push('{= onAuthSucceededRedirectTo =}')
@ -127,10 +154,11 @@ export const LoginSignupForm = ({
}); });
{=/ isUsernameAndPasswordAuthEnabled =} {=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =} {=# isEmailAuthEnabled =}
const { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal } = useEmail({ const { handleSubmit } = useEmail({
isLogin: state === 'login', isLogin,
onError: onErrorHandler, onError: onErrorHandler,
showEmailVerificationPending() { showEmailVerificationPending() {
hookForm.reset()
setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`) setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`)
}, },
onLoginSuccess() { onLoginSuccess() {
@ -145,13 +173,12 @@ export const LoginSignupForm = ({
}); });
{=/ isEmailAuthEnabled =} {=/ isEmailAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =} {=# isAnyPasswordBasedAuthEnabled =}
async function onSubmit (event: FormEvent<HTMLFormElement>) { async function onSubmit (data) {
event.preventDefault();
setIsLoading(true); setIsLoading(true);
setErrorMessage(null); setErrorMessage(null);
setSuccessMessage(null); setSuccessMessage(null);
try { try {
await handleSubmit(); await handleSubmit(data);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -184,41 +211,49 @@ export const LoginSignupForm = ({
</OrContinueWith> </OrContinueWith>
{=/ areBothSocialAndPasswordBasedAuthEnabled =} {=/ areBothSocialAndPasswordBasedAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =} {=# isAnyPasswordBasedAuthEnabled =}
<Form onSubmit={onSubmit}> <Form onSubmit={hookFormHandleSubmit(onSubmit)}>
{=# isUsernameAndPasswordAuthEnabled =} {=# isUsernameAndPasswordAuthEnabled =}
<FormItemGroup> <FormItemGroup>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormInput <FormInput
{...register('username', {
required: 'Username is required',
})}
type="text" type="text"
required
value={usernameFieldVal}
onChange={e => setUsernameFieldVal(e.target.value)}
disabled={isLoading} disabled={isLoading}
/> />
{errors.username && <FormError>{errors.username.message}</FormError>}
</FormItemGroup> </FormItemGroup>
{=/ isUsernameAndPasswordAuthEnabled =} {=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =} {=# isEmailAuthEnabled =}
<FormItemGroup> <FormItemGroup>
<FormLabel>E-mail</FormLabel> <FormLabel>E-mail</FormLabel>
<FormInput <FormInput
{...register('email', {
required: 'Email is required',
})}
type="email" type="email"
required
value={emailFieldVal}
onChange={e => setEmailFieldVal(e.target.value)}
disabled={isLoading} disabled={isLoading}
/> />
{errors.email && <FormError>{errors.email.message}</FormError>}
</FormItemGroup> </FormItemGroup>
{=/ isEmailAuthEnabled =} {=/ isEmailAuthEnabled =}
<FormItemGroup> <FormItemGroup>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormInput <FormInput
{...register('password', {
required: 'Password is required',
})}
type="password" type="password"
required
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
disabled={isLoading} disabled={isLoading}
/> />
{errors.password && <FormError>{errors.password.message}</FormError>}
</FormItemGroup> </FormItemGroup>
<AdditionalFormFields
hookForm={hookForm}
formState={{ isLoading }}
additionalSignupFields={additionalSignupFields}
/>
<FormItemGroup> <FormItemGroup>
<SubmitButton type="submit" disabled={isLoading}>{cta}</SubmitButton> <SubmitButton type="submit" disabled={isLoading}>{cta}</SubmitButton>
</FormItemGroup> </FormItemGroup>
@ -226,3 +261,76 @@ export const LoginSignupForm = ({
{=/ isAnyPasswordBasedAuthEnabled =} {=/ isAnyPasswordBasedAuthEnabled =}
</>) </>)
} }
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

@ -1,21 +1,21 @@
import { useContext } from 'react'
import { useForm } from 'react-hook-form'
import { requestPasswordReset } from '../../../email/actions/passwordReset.js' import { requestPasswordReset } from '../../../email/actions/passwordReset.js'
import { useState, useContext, FormEvent } from 'react' import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form'
import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton } from '../Form'
import { AuthContext } from '../../Auth' import { AuthContext } from '../../Auth'
export const ForgotPasswordForm = () => { export const ForgotPasswordForm = () => {
const { register, handleSubmit, reset, formState: { errors } } = useForm<{ email: string }>()
const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext) const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext)
const [email, setEmail] = useState('')
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { const onSubmit = async (data) => {
event.preventDefault()
setIsLoading(true) setIsLoading(true)
setErrorMessage(null) setErrorMessage(null)
setSuccessMessage(null) setSuccessMessage(null)
try { try {
await requestPasswordReset({ email }) await requestPasswordReset(data)
reset()
setSuccessMessage('Check your email for a password reset link.') setSuccessMessage('Check your email for a password reset link.')
setEmail('')
} catch (error) { } catch (error) {
setErrorMessage({ setErrorMessage({
title: error.message, title: error.message,
@ -28,16 +28,17 @@ export const ForgotPasswordForm = () => {
return ( return (
<> <>
<Form onSubmit={onSubmit}> <Form onSubmit={handleSubmit(onSubmit)}>
<FormItemGroup> <FormItemGroup>
<FormLabel>E-mail</FormLabel> <FormLabel>E-mail</FormLabel>
<FormInput <FormInput
{...register('email', {
required: 'Email is required',
})}
type="email" type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading} disabled={isLoading}
/> />
{errors.email && <FormError>{errors.email.message}</FormError>}
</FormItemGroup> </FormItemGroup>
<FormItemGroup> <FormItemGroup>
<SubmitButton type="submit" disabled={isLoading}> <SubmitButton type="submit" disabled={isLoading}>

View File

@ -1,19 +1,16 @@
import { useContext } from 'react'
import { useForm } from 'react-hook-form'
import { resetPassword } from '../../../email/actions/passwordReset.js' import { resetPassword } from '../../../email/actions/passwordReset.js'
import { useState, useContext, FormEvent } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton } from '../Form' import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form'
import { AuthContext } from '../../Auth' import { AuthContext } from '../../Auth'
export const ResetPasswordForm = () => { export const ResetPasswordForm = () => {
const { register, handleSubmit, reset, formState: { errors } } = useForm<{ password: string; passwordConfirmation: string }>()
const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext) const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext)
const location = useLocation() const location = useLocation()
const token = new URLSearchParams(location.search).get('token') const token = new URLSearchParams(location.search).get('token')
const [password, setPassword] = useState('') const onSubmit = async (data) => {
const [passwordConfirmation, setPasswordConfirmation] = useState('')
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!token) { if (!token) {
setErrorMessage({ setErrorMessage({
title: title:
@ -22,7 +19,7 @@ export const ResetPasswordForm = () => {
return return
} }
if (!password || password !== passwordConfirmation) { if (!data.password || data.password !== data.passwordConfirmation) {
setErrorMessage({ title: `Passwords don't match!` }) setErrorMessage({ title: `Passwords don't match!` })
return return
} }
@ -31,10 +28,9 @@ export const ResetPasswordForm = () => {
setErrorMessage(null) setErrorMessage(null)
setSuccessMessage(null) setSuccessMessage(null)
try { try {
await resetPassword({ password, token }) await resetPassword({ password: data.password, token })
reset()
setSuccessMessage('Your password has been reset.') setSuccessMessage('Your password has been reset.')
setPassword('')
setPasswordConfirmation('')
} catch (error) { } catch (error) {
setErrorMessage({ setErrorMessage({
title: error.message, title: error.message,
@ -47,26 +43,32 @@ export const ResetPasswordForm = () => {
return ( return (
<> <>
<Form onSubmit={onSubmit}> <Form onSubmit={handleSubmit(onSubmit)}>
<FormItemGroup> <FormItemGroup>
<FormLabel>New password</FormLabel> <FormLabel>New password</FormLabel>
<FormInput <FormInput
{...register('password', {
required: 'Password is required',
})}
type="password" type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading} disabled={isLoading}
/> />
{errors.passwordConfirmation && (
<FormError>{errors.passwordConfirmation.message}</FormError>
)}
</FormItemGroup> </FormItemGroup>
<FormItemGroup> <FormItemGroup>
<FormLabel>Confirm new password</FormLabel> <FormLabel>Confirm new password</FormLabel>
<FormInput <FormInput
{...register('passwordConfirmation', {
required: 'Password confirmation is required',
})}
type="password" type="password"
required
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
disabled={isLoading} disabled={isLoading}
/> />
{errors.passwordConfirmation && (
<FormError>{errors.passwordConfirmation.message}</FormError>
)}
</FormItemGroup> </FormItemGroup>
<FormItemGroup> <FormItemGroup>
<SubmitButton type="submit" disabled={isLoading}> <SubmitButton type="submit" disabled={isLoading}>

View File

@ -1,4 +1,3 @@
import { useState } from 'react'
import { signup } from '../../../email/actions/signup' import { signup } from '../../../email/actions/signup'
import { login } from '../../../email/actions/login' import { login } from '../../../email/actions/login'
@ -15,25 +14,20 @@ export function useEmail({
isLogin: boolean isLogin: boolean
isEmailVerificationRequired: boolean isEmailVerificationRequired: boolean
}) { }) {
const [emailFieldVal, setEmailFieldVal] = useState('') async function handleSubmit(data) {
const [passwordFieldVal, setPasswordFieldVal] = useState('')
async function handleSubmit() {
try { try {
if (isLogin) { if (isLogin) {
await login({ email: emailFieldVal, password: passwordFieldVal }) await login(data)
onLoginSuccess() onLoginSuccess()
} else { } else {
await signup({ email: emailFieldVal, password: passwordFieldVal }) await signup(data)
if (isEmailVerificationRequired) { if (isEmailVerificationRequired) {
showEmailVerificationPending() showEmailVerificationPending()
} else { } else {
await login({ email: emailFieldVal, password: passwordFieldVal }) await login(data)
onLoginSuccess() onLoginSuccess()
} }
} }
setEmailFieldVal('')
setPasswordFieldVal('')
} catch (err: unknown) { } catch (err: unknown) {
onError(err as Error) onError(err as Error)
} }
@ -41,9 +35,5 @@ export function useEmail({
return { return {
handleSubmit, handleSubmit,
emailFieldVal,
passwordFieldVal,
setEmailFieldVal,
setPasswordFieldVal,
} }
} }

View File

@ -1,4 +1,3 @@
import { useState } from 'react'
import signup from '../../../signup' import signup from '../../../signup'
import login from '../../../login' import login from '../../../login'
@ -11,21 +10,13 @@ export function useUsernameAndPassword({
onSuccess: () => void onSuccess: () => void
isLogin: boolean isLogin: boolean
}) { }) {
const [usernameFieldVal, setUsernameFieldVal] = useState('') async function handleSubmit(data) {
const [passwordFieldVal, setPasswordFieldVal] = useState('')
async function handleSubmit() {
try { try {
if (!isLogin) { if (!isLogin) {
await signup({ await signup(data)
username: usernameFieldVal,
password: passwordFieldVal,
})
} }
await login(usernameFieldVal, passwordFieldVal) await login(data.username, data.password)
setUsernameFieldVal('')
setPasswordFieldVal('')
onSuccess() onSuccess()
} catch (err: unknown) { } catch (err: unknown) {
onError(err as Error) onError(err as Error)
@ -34,9 +25,5 @@ export function useUsernameAndPassword({
return { return {
handleSubmit, handleSubmit,
usernameFieldVal,
passwordFieldVal,
setUsernameFieldVal,
setPasswordFieldVal,
} }
} }

View File

@ -1,5 +1,7 @@
{{={= =}=}} {{={= =}=}}
import { createTheme } from '@stitches/react' import { createTheme } from '@stitches/react'
import { UseFormReturn, RegisterOptions } from 'react-hook-form'
import type { LoginSignupFormFields } from './internal/common/LoginSignupForm'
export enum State { export enum State {
Login = 'login', Login = 'login',
@ -21,3 +23,23 @@ export type ErrorMessage = {
title: string title: string
description?: 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

@ -12,6 +12,7 @@ export const {
gray500: 'gainsboro', gray500: 'gainsboro',
gray400: '#f0f0f0', gray400: '#f0f0f0',
red: '#FED7D7', red: '#FED7D7',
darkRed: '#fa3838',
green: '#C6F6D5', green: '#C6F6D5',
brand: '$waspYellow', brand: '$waspYellow',
@ -23,6 +24,7 @@ export const {
submitButtonText: 'black', submitButtonText: 'black',
formErrorText: '$darkRed',
}, },
fontSizes: { fontSizes: {
sm: '0.875rem' sm: '0.875rem'

View File

@ -0,0 +1,7 @@
{{={= =}=}}
{=# isEmailAuthEnabled =}
export { defineAdditionalSignupFields } from './providers/email/types.js';
{=/ isEmailAuthEnabled =}
{=# isLocalAuthEnabled =}
export { defineAdditionalSignupFields } from './providers/local/types.js';
{=/ isLocalAuthEnabled =}

View File

@ -11,6 +11,7 @@ import {
isEmailResendAllowed, isEmailResendAllowed,
} from "../../utils.js"; } from "../../utils.js";
import { GetVerificationEmailContentFn } from './types.js'; import { GetVerificationEmailContentFn } from './types.js';
import { validateAndGetAdditionalFields } from '../../utils.js'
export function getSignupRoute({ export function getSignupRoute({
fromField, fromField,
@ -41,8 +42,11 @@ export function getSignupRoute({
} }
await deleteUser(existingUser); await deleteUser(existingUser);
} }
const additionalFields = await validateAndGetAdditionalFields(userFields);
const user = await createUser({ const user = await createUser({
...additionalFields,
email: userFields.email, email: userFields.email,
password: userFields.password, password: userFields.password,
}); });

View File

@ -1,3 +1,5 @@
import { createDefineAdditionalSignupFieldsFn } from '../types.js'
export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent; export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent;
export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent; export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent;
@ -11,3 +13,5 @@ type EmailContent = {
export const tokenVerificationErrors = { export const tokenVerificationErrors = {
TokenExpiredError: 'TokenExpiredError', TokenExpiredError: 'TokenExpiredError',
}; };
export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"email" | "password">()

View File

@ -1,11 +1,15 @@
{{={= =}=}} {{={= =}=}}
import { handleRejection } from '../../../utils.js' import { handleRejection } from '../../../utils.js'
import { createUser } from '../../utils.js' import { createUser } from '../../utils.js'
import { validateAndGetAdditionalFields } from '../../utils.js'
export default handleRejection(async (req, res) => { export default handleRejection(async (req, res) => {
const userFields = req.body || {} const userFields = req.body || {}
const additionalFields = await validateAndGetAdditionalFields(userFields)
await createUser({ await createUser({
...additionalFields,
username: userFields.username, username: userFields.username,
password: userFields.password, password: userFields.password,
}) })

View File

@ -0,0 +1,3 @@
import { createDefineAdditionalSignupFieldsFn } from '../types.js'
export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"username" | "password">()

View File

@ -1,4 +1,6 @@
import type { Router, Request } from "express" import type { Router, Request } from 'express'
import type { User } from '../../entities'
import type { Expand } from '../../universal/types'
export type ProviderConfig = { export type ProviderConfig = {
// Unique provider identifier, used as part of URL paths // Unique provider identifier, used as part of URL paths
@ -17,3 +19,23 @@ export type InitData = {
} }
export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
export function createDefineAdditionalSignupFieldsFn<
// Wasp already includes these fields in the signup process
ExistingFields extends keyof User,
PossibleAdditionalFields = Expand<
Partial<Omit<User, ExistingFields>>
>
>() {
return function defineFields(config: {
[key in keyof PossibleAdditionalFields]: FieldGetter<
PossibleAdditionalFields[key]
>
}) {
return config
}
}
type FieldGetter<T> = (
data: { [key: string]: unknown }
) => Promise<T | undefined> | T | undefined

View File

@ -12,6 +12,19 @@ import { isValidEmail } from '../core/auth/validators.js'
import { emailSender } from '../email/index.js'; import { emailSender } from '../email/index.js';
import { Email } from '../email/core/types.js'; import { Email } from '../email/core/types.js';
{=/ isEmailAuthEnabled =} {=/ isEmailAuthEnabled =}
{=# additionalSignupFields.isDefined =}
{=& additionalSignupFields.importStatement =}
{=/ additionalSignupFields.isDefined =}
{=# additionalSignupFields.isDefined =}
const _waspAdditionalSignupFieldsConfig = {= additionalSignupFields.importIdentifier =}
{=/ additionalSignupFields.isDefined =}
{=^ additionalSignupFields.isDefined =}
import { createDefineAdditionalSignupFieldsFn } from './providers/types.js'
const _waspAdditionalSignupFieldsConfig = {} as ReturnType<
ReturnType<typeof createDefineAdditionalSignupFieldsFn<never>>
>
{=/ additionalSignupFields.isDefined =}
type {= userEntityUpper =}Id = {= userEntityUpper =}['id'] type {= userEntityUpper =}Id = {= userEntityUpper =}['id']
@ -218,4 +231,23 @@ function rethrowPossiblePrismaError(e: unknown): void {
function throwValidationError(message: string): void { function throwValidationError(message: string): void {
throw new HttpError(422, 'Validation failed', { message }) throw new HttpError(422, 'Validation failed', { message })
} }
export async function validateAndGetAdditionalFields(data: {
[key: string]: unknown
}) {
const {
password: _password,
...sanitizedData
} = data;
const result: Record<string, any> = {};
for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) {
try {
const value = await getFieldValue(sanitizedData)
result[field] = value
} catch (e) {
throwValidationError(e.message)
}
}
return result;
}

View File

@ -270,7 +270,7 @@
"file", "file",
"web-app/package.json" "web-app/package.json"
], ],
"ee9766b7c88b3d4ac36b1dd4b3237ea750e4c26ad27bcd18006225707d042e18" "8bacfb3d4e24886405c2a8fa94be0be7d3ec4b882063e5f22667893811cd4371"
], ],
[ [
[ [

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}} {"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -18,6 +18,7 @@
"mitt": "3.0.0", "mitt": "3.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"react-router-dom": "^5.3.3", "react-router-dom": "^5.3.3",
"superjson": "^1.12.2" "superjson": "^1.12.2"
}, },

View File

@ -1,7 +1,7 @@
app waspBuild { app waspBuild {
db: { system: PostgreSQL }, db: { system: PostgreSQL },
wasp: { wasp: {
version: "^0.11.3" version: "^0.11.4"
}, },
title: "waspBuild" title: "waspBuild"
} }

View File

@ -284,7 +284,7 @@
"file", "file",
"web-app/package.json" "web-app/package.json"
], ],
"8a2249588d7cf9ac7c6c8cb979727c264446581f60c7062e5852aef4c1d8a675" "c2b7000a7380cce059cdafe67fa755a8e5f4d1de5e13eb11e545a3ee4d32db0d"
], ],
[ [
[ [

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}} {"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -18,6 +18,7 @@
"mitt": "3.0.0", "mitt": "3.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"react-router-dom": "^5.3.3", "react-router-dom": "^5.3.3",
"superjson": "^1.12.2" "superjson": "^1.12.2"
}, },

View File

@ -1,6 +1,6 @@
app waspCompile { app waspCompile {
wasp: { wasp: {
version: "^0.11.3" version: "^0.11.4"
}, },
title: "waspCompile" title: "waspCompile"
} }

View File

@ -179,14 +179,14 @@
"file", "file",
"server/src/auth/providers/types.ts" "server/src/auth/providers/types.ts"
], ],
"9859bf91e0abe0aaadf7b8c74c573607f6085a5414071836cef6ba84b2bebb69" "323555d76755fe32b21084f063caf931faabcb5937c279cc706bbecad3361d43"
], ],
[ [
[ [
"file", "file",
"server/src/auth/utils.ts" "server/src/auth/utils.ts"
], ],
"b611de9a6b546f6f1cec4497a4cb525150399131dfba32b53ba34afb04e62e96" "1cfb0c8095ba0ed7686229b22e8a9edd971526d50660f88d2cbb988af9b5af30"
], ],
[ [
[ [
@ -578,7 +578,7 @@
"file", "file",
"web-app/package.json" "web-app/package.json"
], ],
"fc5d4d8e4a3ed36972eac6891122dfd3d8edb4074a8df041162dcb38bb6f6d2d" "7857f3a9fc85cd39f16dbd9362bccc5e5662b5b216da45b843ebca6bba4142b5"
], ],
[ [
[ [
@ -655,7 +655,7 @@
"file", "file",
"web-app/src/auth/forms/Auth.tsx" "web-app/src/auth/forms/Auth.tsx"
], ],
"d40cf940a499fdd4b137dcf9f3cd4fbe0bbab4b7c44eb7819b41daeaa861050b" "fc6c204f73999f556eab441772e66dd4dbd433bd69aa2c9e64e713fb2921a886"
], ],
[ [
[ [
@ -669,14 +669,14 @@
"file", "file",
"web-app/src/auth/forms/Signup.tsx" "web-app/src/auth/forms/Signup.tsx"
], ],
"cacd5348e84d42bc142f6c6e00051a8e86f6b17eb037b2772772f073925bf570" "a38124a9a250a603ef6d04dbcb46c04084ace676e9de5fc31f229405d87f47d4"
], ],
[ [
[ [
"file", "file",
"web-app/src/auth/forms/internal/Form.tsx" "web-app/src/auth/forms/internal/Form.tsx"
], ],
"ce6b409fda73d88e762b27aef7038618961e61e6ef8e5f66912325ab88a8223e" "b9b21954b919f173b751c0078aed8303cc15d88df9e9874228efcae7976f26cd"
], ],
[ [
[ [
@ -690,7 +690,7 @@
"file", "file",
"web-app/src/auth/forms/internal/common/LoginSignupForm.tsx" "web-app/src/auth/forms/internal/common/LoginSignupForm.tsx"
], ],
"c92bab325f51159c3d1bb285c7e807acbb85069c5b16afc3a35fbd0121c91b6a" "f211f57dca3f10f08a3e618d27bf8006d26f6832bb7e40f8a22ae44f2d42531e"
], ],
[ [
[ [
@ -711,7 +711,7 @@
"file", "file",
"web-app/src/auth/forms/types.ts" "web-app/src/auth/forms/types.ts"
], ],
"c4066fbd39ec20a3d43be9f9d5762d555dbc006057579ac168a67b2678918a13" "992ca4b2c8e30536636143c556e6bdcc5d5d0d86c1eb2e119171e25d5c33b4e3"
], ],
[ [
[ [
@ -935,7 +935,7 @@
"file", "file",
"web-app/src/stitches.config.js" "web-app/src/stitches.config.js"
], ],
"7de37836b80021870f286ff14d275e2ca7a1c2aa113ba5a5624ed0c77e178f76" "f238234a9db89d6a34c7a8c7c948a58c011da8e167ff94d72e7c6808beb4e177"
], ],
[ [
[ [

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}} {"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -1,4 +1,6 @@
import type { Router, Request } from "express" import type { Router, Request } from 'express'
import type { User } from '../../entities'
import type { Expand } from '../../universal/types'
export type ProviderConfig = { export type ProviderConfig = {
// Unique provider identifier, used as part of URL paths // Unique provider identifier, used as part of URL paths
@ -17,3 +19,23 @@ export type InitData = {
} }
export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }
export function createDefineAdditionalSignupFieldsFn<
// Wasp already includes these fields in the signup process
ExistingFields extends keyof User,
PossibleAdditionalFields = Expand<
Partial<Omit<User, ExistingFields>>
>
>() {
return function defineFields(config: {
[key in keyof PossibleAdditionalFields]: FieldGetter<
PossibleAdditionalFields[key]
>
}) {
return config
}
}
type FieldGetter<T> = (
data: { [key: string]: unknown }
) => Promise<T | undefined> | T | undefined

View File

@ -7,6 +7,11 @@ import { type User } from '../entities/index.js'
import waspServerConfig from '../config.js'; import waspServerConfig from '../config.js';
import { type Prisma } from '@prisma/client'; import { type Prisma } from '@prisma/client';
import { createDefineAdditionalSignupFieldsFn } from './providers/types.js'
const _waspAdditionalSignupFieldsConfig = {} as ReturnType<
ReturnType<typeof createDefineAdditionalSignupFieldsFn<never>>
>
type UserId = User['id'] type UserId = User['id']
export const contextWithUserEntity = { export const contextWithUserEntity = {
@ -73,4 +78,23 @@ function rethrowPossiblePrismaError(e: unknown): void {
function throwValidationError(message: string): void { function throwValidationError(message: string): void {
throw new HttpError(422, 'Validation failed', { message }) throw new HttpError(422, 'Validation failed', { message })
} }
export async function validateAndGetAdditionalFields(data: {
[key: string]: unknown
}) {
const {
password: _password,
...sanitizedData
} = data;
const result: Record<string, any> = {};
for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) {
try {
const value = await getFieldValue(sanitizedData)
result[field] = value
} catch (e) {
throwValidationError(e.message)
}
}
return result;
}

View File

@ -19,6 +19,7 @@
"mitt": "3.0.0", "mitt": "3.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"react-redux": "^7.1.3", "react-redux": "^7.1.3",
"react-router-dom": "^5.3.3", "react-router-dom": "^5.3.3",
"redux": "^4.0.5", "redux": "^4.0.5",

View File

@ -6,6 +6,7 @@ import {
type State, type State,
type CustomizationOptions, type CustomizationOptions,
type ErrorMessage, type ErrorMessage,
type AdditionalSignupFields,
} from './types' } from './types'
import { LoginSignupForm } from './internal/common/LoginSignupForm' import { LoginSignupForm } from './internal/common/LoginSignupForm'
import { MessageError, MessageSuccess } from './internal/Message' import { MessageError, MessageSuccess } from './internal/Message'
@ -33,9 +34,11 @@ export const AuthContext = createContext({
setSuccessMessage: (successMessage: string | null) => {}, setSuccessMessage: (successMessage: string | null) => {},
}) })
function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: { function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: {
state: State; state: State;
} & CustomizationOptions) { } & CustomizationOptions & {
additionalSignupFields?: AdditionalSignupFields;
}) {
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null); const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -71,6 +74,7 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
<LoginSignupForm <LoginSignupForm
state={state} state={state}
socialButtonsDirection={socialButtonsDirection} socialButtonsDirection={socialButtonsDirection}
additionalSignupFields={additionalSignupFields}
/> />
)} )}
</AuthContext.Provider> </AuthContext.Provider>

View File

@ -1,17 +1,23 @@
import Auth from './Auth' import Auth from './Auth'
import { type CustomizationOptions, State } from './types' import {
type CustomizationOptions,
type AdditionalSignupFields,
State,
} from './types'
export function SignupForm({ export function SignupForm({
appearance, appearance,
logo, logo,
socialLayout, socialLayout,
}: CustomizationOptions) { additionalFields,
}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) {
return ( return (
<Auth <Auth
appearance={appearance} appearance={appearance}
logo={logo} logo={logo}
socialLayout={socialLayout} socialLayout={socialLayout}
state={State.Signup} state={State.Signup}
additionalSignupFields={additionalFields}
/> />
) )
} }

View File

@ -14,9 +14,10 @@ export const FormLabel = styled('label', {
display: 'block', display: 'block',
fontSize: '$sm', fontSize: '$sm',
fontWeight: '500', fontWeight: '500',
marginBottom: '0.5rem',
}) })
export const FormInput = styled('input', { const commonInputStyles = {
display: 'block', display: 'block',
lineHeight: '1.5rem', lineHeight: '1.5rem',
fontSize: '$sm', fontSize: '$sm',
@ -44,7 +45,18 @@ export const FormInput = styled('input', {
paddingBottom: '0.375rem', paddingBottom: '0.375rem',
paddingLeft: '0.75rem', paddingLeft: '0.75rem',
paddingRight: '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', marginTop: '0.5rem',
}) })

View File

@ -1,9 +1,22 @@
import { useContext, type FormEvent } from 'react' import { useContext } from 'react'
import { styled } from '../../../../stitches.config' import { useForm, UseFormReturn } from 'react-hook-form'
import config from '../../../../config.js'
import { AuthContext } from '../../Auth' import { AuthContext } from '../../Auth'
import { Form, FormInput, FormItemGroup, FormLabel, SubmitButton } from '../Form' import {
Form,
FormInput,
FormItemGroup,
FormLabel,
FormError,
FormTextarea,
SubmitButton,
} from '../Form'
import type {
AdditionalSignupFields,
AdditionalSignupField,
AdditionalSignupFieldRenderFn,
FormState,
} from '../../types'
import * as SocialIcons from '../social/SocialIcons' import * as SocialIcons from '../social/SocialIcons'
import { SocialButton } from '../social/SocialButton' import { SocialButton } from '../social/SocialButton'
@ -46,12 +59,18 @@ const SocialAuthButtons = styled('div', {
}) })
const googleSignInUrl = `${config.apiUrl}/auth/google/login` const googleSignInUrl = `${config.apiUrl}/auth/google/login`
export type LoginSignupFormFields = {
[key: string]: string;
}
export const LoginSignupForm = ({ export const LoginSignupForm = ({
state, state,
socialButtonsDirection = 'horizontal', socialButtonsDirection = 'horizontal',
additionalSignupFields,
}: { }: {
state: 'login' | 'signup', state: 'login' | 'signup'
socialButtonsDirection?: 'horizontal' | 'vertical'; socialButtonsDirection?: 'horizontal' | 'vertical'
additionalSignupFields?: AdditionalSignupFields
}) => { }) => {
const { const {
isLoading, isLoading,
@ -59,7 +78,10 @@ export const LoginSignupForm = ({
setSuccessMessage, setSuccessMessage,
setIsLoading, setIsLoading,
} = useContext(AuthContext) } = useContext(AuthContext)
const cta = state === 'login' ? 'Log in' : 'Sign up'; const isLogin = state === 'login'
const cta = isLogin ? 'Log in' : 'Sign up';
const hookForm = useForm<LoginSignupFormFields>()
const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
return (<> return (<>
<SocialAuth> <SocialAuth>
@ -71,3 +93,76 @@ export const LoginSignupForm = ({
</SocialAuth> </SocialAuth>
</>) </>)
} }
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

@ -1,4 +1,6 @@
import { createTheme } from '@stitches/react' import { createTheme } from '@stitches/react'
import { UseFormReturn, RegisterOptions } from 'react-hook-form'
import type { LoginSignupFormFields } from './internal/common/LoginSignupForm'
export enum State { export enum State {
Login = 'login', Login = 'login',
@ -15,3 +17,23 @@ export type ErrorMessage = {
title: string title: string
description?: 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

@ -12,6 +12,7 @@ export const {
gray500: 'gainsboro', gray500: 'gainsboro',
gray400: '#f0f0f0', gray400: '#f0f0f0',
red: '#FED7D7', red: '#FED7D7',
darkRed: '#fa3838',
green: '#C6F6D5', green: '#C6F6D5',
brand: '$waspYellow', brand: '$waspYellow',
@ -23,6 +24,7 @@ export const {
submitButtonText: 'black', submitButtonText: 'black',
formErrorText: '$darkRed',
}, },
fontSizes: { fontSizes: {
sm: '0.875rem' sm: '0.875rem'

View File

@ -1,7 +1,7 @@
app waspComplexTest { app waspComplexTest {
db: { system: PostgreSQL }, db: { system: PostgreSQL },
wasp: { wasp: {
version: "^0.11.3" version: "^0.11.4"
}, },
auth: { auth: {
userEntity: User, userEntity: User,

View File

@ -326,7 +326,7 @@
"file", "file",
"web-app/package.json" "web-app/package.json"
], ],
"57da965d01f5d39d74c6ca91f00d84ba5b6c78660ee837266b2622e410e4fd8e" "adcc3a24462553bd66d4c14f43cd351512d28c249d96d3cd3e1e6dad834770fd"
], ],
[ [
[ [

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"pg-boss","version":"^8.4.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}} {"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"pg-boss","version":"^8.4.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -18,6 +18,7 @@
"mitt": "3.0.0", "mitt": "3.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"react-router-dom": "^5.3.3", "react-router-dom": "^5.3.3",
"superjson": "^1.12.2" "superjson": "^1.12.2"
}, },

View File

@ -1,7 +1,7 @@
app waspJob { app waspJob {
db: { system: PostgreSQL }, db: { system: PostgreSQL },
wasp: { wasp: {
version: "^0.11.3" version: "^0.11.4"
}, },
title: "waspJob" title: "waspJob"
} }

View File

@ -284,7 +284,7 @@
"file", "file",
"web-app/package.json" "web-app/package.json"
], ],
"7e237189e89ac549b485ffef329aaa6699c874fd16b8e890f37f412ad7715219" "fd7ca891ab5d232690015d34110d34a180906e1bb7c37e2432ebad2356b860a3"
], ],
[ [
[ [

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}} {"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.12.0"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.12.0"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.11.9"},{"name":"@tsconfig/node18","version":"^1.0.1"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.12.0"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -18,6 +18,7 @@
"mitt": "3.0.0", "mitt": "3.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"react-router-dom": "^5.3.3", "react-router-dom": "^5.3.3",
"superjson": "^1.12.2" "superjson": "^1.12.2"
}, },

View File

@ -1,6 +1,6 @@
app waspMigrate { app waspMigrate {
wasp: { wasp: {
version: "^0.11.3" version: "^0.11.4"
}, },
title: "waspMigrate" title: "waspMigrate"
} }

View File

@ -1,6 +1,6 @@
app waspNew { app waspNew {
wasp: { wasp: {
version: "^0.11.3" version: "^0.11.4"
}, },
title: "waspNew" title: "waspNew"
} }

View File

@ -12,7 +12,13 @@ app crudTesting {
usernameAndPassword: {}, usernameAndPassword: {},
}, },
onAuthFailedRedirectTo: "/login", onAuthFailedRedirectTo: "/login",
signup: {
additionalFields: import { fields } from "@server/auth.js",
},
}, },
dependencies: [
("zod", "^3.22.2")
],
db: { db: {
system: PostgreSQL system: PostgreSQL
} }
@ -44,6 +50,7 @@ entity User {=psl
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique username String @unique
password String password String
address String?
tasks Task[] tasks Task[]
psl=} psl=}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "address" TEXT;

View File

@ -0,0 +1,5 @@
module.exports = {
trailingComma: 'es5',
semi: false,
singleQuote: true,
}

View File

@ -2,6 +2,7 @@ import "./Main.css";
import React, { useState } from "react"; import React, { useState } from "react";
import { Link, routes } from "@wasp/router"; import { Link, routes } from "@wasp/router";
import logout from "@wasp/auth/logout";
import { tasks as tasksCrud } from "@wasp/crud/tasks"; import { tasks as tasksCrud } from "@wasp/crud/tasks";
import { User } from "@wasp/entities"; import { User } from "@wasp/entities";
@ -45,7 +46,7 @@ const MainPage = ({ user }: { user: User }) => {
setEditTaskTitle(""); setEditTaskTitle("");
} }
function handleStartEditing(task: Task) { function handleStartEditing(task: { id: number; title: string }) {
setIsEditing(task.id); setIsEditing(task.id);
setEditTaskTitle(task.title); setEditTaskTitle(task.title);
} }
@ -122,6 +123,7 @@ const MainPage = ({ user }: { user: User }) => {
Create task Create task
</button> </button>
</form> </form>
<button onClick={logout}>Logout</button>
</main> </main>
</div> </div>
); );

View File

@ -1,12 +1,24 @@
import { SignupForm } from "@wasp/auth/forms/Signup"; import { SignupForm } from '@wasp/auth/forms/Signup'
import {
FormError,
FormInput,
FormItemGroup,
FormLabel,
} from '@wasp/auth/forms/internal/Form'
export const SignupPage = () => { export const SignupPage = () => {
return ( return (
<div className="container"> <SignupForm
<main> additionalFields={[
<h1>Signup</h1> {
<SignupForm /> name: 'address',
</main> label: 'Address',
</div> type: 'input',
); validations: {
}; required: 'Address is required'
}
}
]}
/>
)
}

View File

@ -0,0 +1,19 @@
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
import * as z from 'zod'
export const fields = defineAdditionalSignupFields({
address: (data) => {
console.log('Received data:', data)
const AddressSchema = z
.string({
required_error: 'Address is required',
invalid_type_error: 'Address must be a string',
})
.min(10, 'Address must be at least 10 characters long')
const result = AddressSchema.safeParse(data.address)
if (result.success === false) {
throw new Error(result.error.issues[0].message)
}
return result.data
},
})

View File

@ -0,0 +1,5 @@
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
export const fields = defineAdditionalSignupFields({
address: (data) => data.address,
})

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "address" TEXT;

View File

@ -7,6 +7,7 @@ import getDate from '@wasp/queries/getDate'
import Todo, { areThereAnyTasks } from './Todo' import Todo, { areThereAnyTasks } from './Todo'
import { App } from './App' import { App } from './App'
import { getMe } from '@wasp/auth/useAuth' import { getMe } from '@wasp/auth/useAuth'
import type { User } from '@wasp/auth/types'
test('areThereAnyTasks', () => { test('areThereAnyTasks', () => {
expect(areThereAnyTasks([])).toBe(false) expect(areThereAnyTasks([])).toBe(false)
@ -38,8 +39,9 @@ const mockUser = {
email: 'elon@tesla.com', email: 'elon@tesla.com',
isEmailVerified: false, isEmailVerified: false,
emailVerificationSentAt: null, emailVerificationSentAt: null,
passwordResetSentAt: null passwordResetSentAt: null,
} address: null,
} satisfies User
test('handles multiple mock data sources', async () => { test('handles multiple mock data sources', async () => {
mockQuery(getMe, mockUser) mockQuery(getMe, mockUser)

View File

@ -7,6 +7,7 @@ import { getTotalTaskCountMessage } from './helpers'
import appearance from './appearance' import appearance from './appearance'
import todoLogo from '../../todoLogo.png' import todoLogo from '../../todoLogo.png'
import { FormItemGroup } from '@wasp/auth/forms/internal/Form'
const Signup = () => { const Signup = () => {
const { data: numTasks } = useQuery(getNumTasks) const { data: numTasks } = useQuery(getNumTasks)
@ -20,6 +21,22 @@ const Signup = () => {
appearance={appearance} appearance={appearance}
logo={todoLogo} logo={todoLogo}
socialLayout="horizontal" socialLayout="horizontal"
additionalFields={[
{
name: 'address',
type: 'input',
label: 'Address',
validations: {
required: 'Address is required',
},
},
() => (
<FormItemGroup className="text-sm text-gray-500">
👉 Don't forget to press the button below to submit the
form.
</FormItemGroup>
),
]}
/> />
<br /> <br />
<span className="text-sm font-medium text-gray-900"> <span className="text-sm font-medium text-gray-900">

View File

@ -0,0 +1,13 @@
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
export const fields = defineAdditionalSignupFields({
address: (data) => {
if (typeof data.address !== 'string') {
throw new Error('Address is required.')
}
if (data.address.length < 10) {
throw new Error('Address must be at least 10 characters long.')
}
return data.address
},
})

View File

@ -41,6 +41,9 @@ app todoApp {
allowUnverifiedLogin: false, allowUnverifiedLogin: false,
}, },
}, },
signup: {
additionalFields: import { fields } from "@server/auth/signup.js",
},
onAuthFailedRedirectTo: "/login", onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/profile" onAuthSucceededRedirectTo: "/profile"
}, },
@ -79,6 +82,7 @@ entity User {=psl
externalAuthAssociations SocialLogin[] externalAuthAssociations SocialLogin[]
// Business logic // Business logic
tasks Task[] tasks Task[]
address String?
psl=} psl=}
entity SocialLogin {=psl entity SocialLogin {=psl

View File

@ -6,6 +6,7 @@ module Wasp.AppSpec.App.Auth
AuthMethods (..), AuthMethods (..),
ExternalAuthConfig (..), ExternalAuthConfig (..),
EmailAuthConfig (..), EmailAuthConfig (..),
SignupOptions (..),
usernameAndPasswordConfig, usernameAndPasswordConfig,
isUsernameAndPasswordAuthEnabled, isUsernameAndPasswordAuthEnabled,
isExternalAuthEnabled, isExternalAuthEnabled,
@ -29,6 +30,7 @@ data Auth = Auth
{ userEntity :: Ref Entity, { userEntity :: Ref Entity,
externalAuthEntity :: Maybe (Ref Entity), externalAuthEntity :: Maybe (Ref Entity),
methods :: AuthMethods, methods :: AuthMethods,
signup :: Maybe SignupOptions,
onAuthFailedRedirectTo :: String, onAuthFailedRedirectTo :: String,
onAuthSucceededRedirectTo :: Maybe String onAuthSucceededRedirectTo :: Maybe String
} }
@ -62,6 +64,11 @@ data EmailAuthConfig = EmailAuthConfig
} }
deriving (Show, Eq, Data) deriving (Show, Eq, Data)
data SignupOptions = SignupOptions
{ additionalFields :: Maybe ExtImport
}
deriving (Show, Eq, Data)
usernameAndPasswordConfig :: UsernameAndPasswordConfig usernameAndPasswordConfig :: UsernameAndPasswordConfig
usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing

View File

@ -390,7 +390,11 @@ genExportedTypesDir spec =
[ C.mkTmplFdWithData [relfile|src/types/index.ts|] (Just tmplData) [ C.mkTmplFdWithData [relfile|src/types/index.ts|] (Just tmplData)
] ]
where where
tmplData = object ["isExternalAuthEnabled" .= isExternalAuthEnabled, "isEmailAuthEnabled" .= isEmailAuthEnabled] tmplData =
object
[ "isExternalAuthEnabled" .= isExternalAuthEnabled,
"isEmailAuthEnabled" .= isEmailAuthEnabled
]
isExternalAuthEnabled = AS.App.Auth.isExternalAuthEnabled <$> maybeAuth isExternalAuthEnabled = AS.App.Auth.isExternalAuthEnabled <$> maybeAuth
isEmailAuthEnabled = AS.App.Auth.isEmailAuthEnabled <$> maybeAuth isEmailAuthEnabled = AS.App.Auth.isEmailAuthEnabled <$> maybeAuth
maybeAuth = AS.App.auth $ snd $ getApp spec maybeAuth = AS.App.auth $ snd $ getApp spec

View File

@ -28,9 +28,12 @@ genLocalAuth auth
sequence sequence
[ genLoginRoute auth, [ genLoginRoute auth,
genSignupRoute auth, genSignupRoute auth,
genLocalAuthConfig genLocalAuthConfig,
genFileCopy [relfile|auth/providers/local/types.ts|]
] ]
| otherwise = return [] | otherwise = return []
where
genFileCopy = return . C.mkSrcTmplFd
genLocalAuthConfig :: Generator FileDraft genLocalAuthConfig :: Generator FileDraft
genLocalAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) genLocalAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)

View File

@ -10,6 +10,7 @@ import StrongPath
Path', Path',
Rel, Rel,
reldir, reldir,
reldirP,
relfile, relfile,
(</>), (</>),
) )
@ -29,6 +30,7 @@ import Wasp.Generator.ServerGenerator.Auth.EmailAuthG (genEmailAuth)
import Wasp.Generator.ServerGenerator.Auth.LocalAuthG (genLocalAuth) import Wasp.Generator.ServerGenerator.Auth.LocalAuthG (genLocalAuth)
import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (genOAuthAuth) import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (genOAuthAuth)
import qualified Wasp.Generator.ServerGenerator.Common as C import qualified Wasp.Generator.ServerGenerator.Common as C
import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson)
import Wasp.Util ((<++>)) import Wasp.Util ((<++>))
import qualified Wasp.Util as Util import qualified Wasp.Util as Util
@ -45,6 +47,7 @@ genAuth spec = case maybeAuth of
genProvidersIndex auth, genProvidersIndex auth,
genFileCopy [relfile|auth/providers/types.ts|] genFileCopy [relfile|auth/providers/types.ts|]
] ]
<++> genIndexTs auth
<++> genLocalAuth auth <++> genLocalAuth auth
<++> genOAuthAuth spec auth <++> genOAuthAuth spec auth
<++> genEmailAuth spec auth <++> genEmailAuth spec auth
@ -125,12 +128,30 @@ genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplDat
"userEntityLower" .= (Util.toLowerFirst userEntityName :: String), "userEntityLower" .= (Util.toLowerFirst userEntityName :: String),
"failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo auth, "failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo auth,
"successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth, "successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth,
"isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth "isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth,
"additionalSignupFields" .= extImportToImportJson [reldirP|../|] additionalSignupFields
] ]
utilsFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' utilsFileInSrcDir :: Path' (Rel C.ServerSrcDir) File'
utilsFileInSrcDir = [relfile|auth/utils.ts|] utilsFileInSrcDir = [relfile|auth/utils.ts|]
additionalSignupFields = AS.Auth.signup auth >>= AS.Auth.additionalFields
genIndexTs :: AS.Auth.Auth -> Generator [FileDraft]
genIndexTs auth =
return $
if isEmailAuthEnabled || isLocalAuthEnabled
then [C.mkTmplFdWithData [relfile|src/auth/index.ts|] (Just tmplData)]
else []
where
tmplData =
object
[ "isEmailAuthEnabled" .= isEmailAuthEnabled,
"isLocalAuthEnabled" .= isLocalAuthEnabled
]
isEmailAuthEnabled = AS.Auth.isEmailAuthEnabled auth
isLocalAuthEnabled = AS.Auth.isUsernameAndPasswordAuthEnabled auth
getOnAuthSucceededRedirectToOrDefault :: AS.Auth.Auth -> String getOnAuthSucceededRedirectToOrDefault :: AS.Auth.Auth -> String
getOnAuthSucceededRedirectToOrDefault auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth) getOnAuthSucceededRedirectToOrDefault auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth)

View File

@ -136,7 +136,9 @@ npmDepsForWasp spec =
-- https://github.com/wasp-lang/wasp/pull/962/ for details). -- https://github.com/wasp-lang/wasp/pull/962/ for details).
("@prisma/client", show prismaVersion), ("@prisma/client", show prismaVersion),
("superjson", "^1.12.2"), ("superjson", "^1.12.2"),
("mitt", "3.0.0") ("mitt", "3.0.0"),
-- Used for Auth UI
("react-hook-form", "^7.45.4")
] ]
++ depsRequiredForAuth spec ++ depsRequiredForAuth spec
++ depsRequiredByTailwind spec ++ depsRequiredByTailwind spec

View File

@ -50,6 +50,9 @@ spec_Analyzer = do
" userEntity: User,", " userEntity: User,",
" methods: { usernameAndPassword: {} },", " methods: { usernameAndPassword: {} },",
" onAuthFailedRedirectTo: \"/\",", " onAuthFailedRedirectTo: \"/\",",
" signup: {",
" additionalFields: import { fields } from \"@server/auth/signup.js\",",
" },",
" },", " },",
" dependencies: [", " dependencies: [",
" (\"redux\", \"^4.0.5\")", " (\"redux\", \"^4.0.5\")",
@ -135,6 +138,12 @@ spec_Analyzer = do
Auth.Auth Auth.Auth
{ Auth.userEntity = Ref "User" :: Ref Entity, { Auth.userEntity = Ref "User" :: Ref Entity,
Auth.externalAuthEntity = Nothing, Auth.externalAuthEntity = Nothing,
Auth.signup =
Just $
Auth.SignupOptions
{ Auth.additionalFields =
Just $ ExtImport (ExtImportField "fields") (fromJust $ SP.parseRelFileP "auth/signup.js")
},
Auth.methods = Auth.methods =
Auth.AuthMethods Auth.AuthMethods
{ Auth.usernameAndPassword = Just Auth.usernameAndPasswordConfig, { Auth.usernameAndPassword = Just Auth.usernameAndPasswordConfig,

View File

@ -6,7 +6,7 @@ cabal-version: 2.4
-- Consider using hpack, or maybe even hpack-dhall. -- Consider using hpack, or maybe even hpack-dhall.
name: waspc name: waspc
version: 0.11.3 version: 0.11.4
description: Please see the README on GitHub at <https://github.com/wasp-lang/wasp/waspc#readme> description: Please see the README on GitHub at <https://github.com/wasp-lang/wasp/waspc#readme>
homepage: https://github.com/wasp-lang/wasp/waspc#readme homepage: https://github.com/wasp-lang/wasp/waspc#readme
bug-reports: https://github.com/wasp-lang/wasp/issues bug-reports: https://github.com/wasp-lang/wasp/issues

File diff suppressed because it is too large Load Diff

View File

@ -218,6 +218,8 @@ export function SignupPage() {
It will automatically show the correct authentication providers based on your `main.wasp` file. It will automatically show the correct authentication providers based on your `main.wasp` file.
Read more about customizing the signup process like adding additional fields or extra UI in the [Using Auth](/docs/auth/overview#customizing-the-signup-process) section.
### Forgot Password Form ### Forgot Password Form
Used with <EmailPill /> authentication. Used with <EmailPill /> authentication.

6
web/prettier.config.js Normal file
View File

@ -0,0 +1,6 @@
// Used to format the code in the docs
module.exports = {
trailingComma: 'es5',
semi: false,
singleQuote: true,
}