diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index 8ec099d30..1a6a2e031 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -2,6 +2,72 @@ ## 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 ( +
+
+

Signup

+ +
+
+ ); +}; +``` ### 🎉 [New Feature] Support for PostgreSQL Extensions Wasp now supports PostgreSQL extensions! You can enable them in your `main.wasp` file: diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx index 9d3911e9b..5c50ba410 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx @@ -7,6 +7,7 @@ import { type State, type CustomizationOptions, type ErrorMessage, + type AdditionalSignupFields, } from './types' import { LoginSignupForm } from './internal/common/LoginSignupForm' import { MessageError, MessageSuccess } from './internal/Message' @@ -39,9 +40,11 @@ export const AuthContext = createContext({ setSuccessMessage: (successMessage: string | null) => {}, }) -function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: { +function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: { state: State; -} & CustomizationOptions) { +} & CustomizationOptions & { + additionalSignupFields?: AdditionalSignupFields; +}) { const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -82,6 +85,7 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: { )} {=# isEmailAuthEnabled =} diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.tsx index b48cf4e91..66ffab450 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/Signup.tsx @@ -1,17 +1,23 @@ import Auth from './Auth' -import { type CustomizationOptions, State } from './types' +import { + type CustomizationOptions, + type AdditionalSignupFields, + State, +} from './types' export function SignupForm({ appearance, logo, socialLayout, -}: CustomizationOptions) { + additionalFields, +}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) { return ( ) } diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/Form.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/Form.tsx index 2095e48b7..24d6c586d 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/Form.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/Form.tsx @@ -14,9 +14,10 @@ export const FormLabel = styled('label', { display: 'block', fontSize: '$sm', fontWeight: '500', + marginBottom: '0.5rem', }) -export const FormInput = styled('input', { +const commonInputStyles = { display: 'block', lineHeight: '1.5rem', fontSize: '$sm', @@ -44,7 +45,18 @@ export const FormInput = styled('input', { 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', }) diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx index f791b2ab4..bfd4848a1 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/common/LoginSignupForm.tsx @@ -1,10 +1,23 @@ {{={= =}=}} -import { useContext, type FormEvent } from 'react' -import { styled } from '../../../../stitches.config' -import config from '../../../../config.js' +import { useContext } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' 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 =} import * as SocialIcons from '../social/SocialIcons' import { SocialButton } from '../social/SocialButton' @@ -97,12 +110,23 @@ const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}` const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}` {=/ 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 = ({ state, socialButtonsDirection = 'horizontal', + additionalSignupFields, }: { - state: 'login' | 'signup', - socialButtonsDirection?: 'horizontal' | 'vertical'; + state: 'login' | 'signup' + socialButtonsDirection?: 'horizontal' | 'vertical' + additionalSignupFields?: AdditionalSignupFields }) => { const { isLoading, @@ -110,16 +134,19 @@ export const LoginSignupForm = ({ setSuccessMessage, setIsLoading, } = useContext(AuthContext) - const cta = state === 'login' ? 'Log in' : 'Sign up'; + const isLogin = state === 'login' + const cta = isLogin ? 'Log in' : 'Sign up'; {=# isAnyPasswordBasedAuthEnabled =} const history = useHistory(); const onErrorHandler = (error) => { setErrorMessage({ title: error.message, description: error.data?.data?.message }) }; {=/ isAnyPasswordBasedAuthEnabled =} + const hookForm = useForm() + const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm {=# isUsernameAndPasswordAuthEnabled =} - const { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal } = useUsernameAndPassword({ - isLogin: state === 'login', + const { handleSubmit } = useUsernameAndPassword({ + isLogin, onError: onErrorHandler, onSuccess() { history.push('{= onAuthSucceededRedirectTo =}') @@ -127,10 +154,11 @@ export const LoginSignupForm = ({ }); {=/ isUsernameAndPasswordAuthEnabled =} {=# isEmailAuthEnabled =} - const { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal } = useEmail({ - isLogin: state === 'login', + const { handleSubmit } = useEmail({ + isLogin, onError: onErrorHandler, showEmailVerificationPending() { + hookForm.reset() setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`) }, onLoginSuccess() { @@ -145,13 +173,12 @@ export const LoginSignupForm = ({ }); {=/ isEmailAuthEnabled =} {=# isAnyPasswordBasedAuthEnabled =} - async function onSubmit (event: FormEvent) { - event.preventDefault(); + async function onSubmit (data) { setIsLoading(true); setErrorMessage(null); setSuccessMessage(null); try { - await handleSubmit(); + await handleSubmit(data); } finally { setIsLoading(false); } @@ -184,41 +211,49 @@ export const LoginSignupForm = ({ {=/ areBothSocialAndPasswordBasedAuthEnabled =} {=# isAnyPasswordBasedAuthEnabled =} -
+ {=# isUsernameAndPasswordAuthEnabled =} Username setUsernameFieldVal(e.target.value)} disabled={isLoading} /> + {errors.username && {errors.username.message}} {=/ isUsernameAndPasswordAuthEnabled =} {=# isEmailAuthEnabled =} E-mail setEmailFieldVal(e.target.value)} disabled={isLoading} /> + {errors.email && {errors.email.message}} {=/ isEmailAuthEnabled =} Password setPasswordFieldVal(e.target.value)} disabled={isLoading} /> + {errors.password && {errors.password.message}} + {cta} @@ -226,3 +261,76 @@ export const LoginSignupForm = ({ {=/ isAnyPasswordBasedAuthEnabled =} ) } + +function AdditionalFormFields({ + hookForm, + formState: { isLoading }, + additionalSignupFields, +}: { + hookForm: UseFormReturn; + formState: FormState; + additionalSignupFields: AdditionalSignupFields; +}) { + const { + register, + formState: { errors }, + } = hookForm; + + function renderField>( + field: AdditionalSignupField, + // Ideally we would use ComponentType here, but it doesn't work with react-hook-form + Component: any, + props?: React.ComponentProps + ) { + return ( + + {field.label} + + {errors[field.name] && ( + {errors[field.name].message} + )} + + ); + } + + 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(field, FormInput, { + type: 'text', + }) + case 'textarea': + return renderField(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' +} diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ForgotPasswordForm.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ForgotPasswordForm.tsx index caff1bb20..e20328a4b 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ForgotPasswordForm.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ForgotPasswordForm.tsx @@ -1,21 +1,21 @@ +import { useContext } from 'react' +import { useForm } from 'react-hook-form' import { requestPasswordReset } from '../../../email/actions/passwordReset.js' -import { useState, useContext, FormEvent } from 'react' -import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton } from '../Form' +import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form' import { AuthContext } from '../../Auth' export const ForgotPasswordForm = () => { + const { register, handleSubmit, reset, formState: { errors } } = useForm<{ email: string }>() const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext) - const [email, setEmail] = useState('') - const onSubmit = async (event: FormEvent) => { - event.preventDefault() + const onSubmit = async (data) => { setIsLoading(true) setErrorMessage(null) setSuccessMessage(null) try { - await requestPasswordReset({ email }) + await requestPasswordReset(data) + reset() setSuccessMessage('Check your email for a password reset link.') - setEmail('') } catch (error) { setErrorMessage({ title: error.message, @@ -28,16 +28,17 @@ export const ForgotPasswordForm = () => { return ( <> - + E-mail setEmail(e.target.value)} disabled={isLoading} /> + {errors.email && {errors.email.message}} diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ResetPasswordForm.tsx b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ResetPasswordForm.tsx index 24fdaa45c..a3535d57d 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ResetPasswordForm.tsx +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/ResetPasswordForm.tsx @@ -1,19 +1,16 @@ +import { useContext } from 'react' +import { useForm } from 'react-hook-form' import { resetPassword } from '../../../email/actions/passwordReset.js' -import { useState, useContext, FormEvent } from 'react' 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' export const ResetPasswordForm = () => { + const { register, handleSubmit, reset, formState: { errors } } = useForm<{ password: string; passwordConfirmation: string }>() const { isLoading, setErrorMessage, setSuccessMessage, setIsLoading } = useContext(AuthContext) const location = useLocation() const token = new URLSearchParams(location.search).get('token') - const [password, setPassword] = useState('') - const [passwordConfirmation, setPasswordConfirmation] = useState('') - - const onSubmit = async (event: FormEvent) => { - event.preventDefault() - + const onSubmit = async (data) => { if (!token) { setErrorMessage({ title: @@ -22,7 +19,7 @@ export const ResetPasswordForm = () => { return } - if (!password || password !== passwordConfirmation) { + if (!data.password || data.password !== data.passwordConfirmation) { setErrorMessage({ title: `Passwords don't match!` }) return } @@ -31,10 +28,9 @@ export const ResetPasswordForm = () => { setErrorMessage(null) setSuccessMessage(null) try { - await resetPassword({ password, token }) + await resetPassword({ password: data.password, token }) + reset() setSuccessMessage('Your password has been reset.') - setPassword('') - setPasswordConfirmation('') } catch (error) { setErrorMessage({ title: error.message, @@ -47,26 +43,32 @@ export const ResetPasswordForm = () => { return ( <> - + New password setPassword(e.target.value)} disabled={isLoading} /> + {errors.passwordConfirmation && ( + {errors.passwordConfirmation.message} + )} Confirm new password setPasswordConfirmation(e.target.value)} disabled={isLoading} /> + {errors.passwordConfirmation && ( + {errors.passwordConfirmation.message} + )} diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts index 1c7631527..f5f4e371c 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/email/useEmail.ts @@ -1,4 +1,3 @@ -import { useState } from 'react' import { signup } from '../../../email/actions/signup' import { login } from '../../../email/actions/login' @@ -15,25 +14,20 @@ export function useEmail({ isLogin: boolean isEmailVerificationRequired: boolean }) { - const [emailFieldVal, setEmailFieldVal] = useState('') - const [passwordFieldVal, setPasswordFieldVal] = useState('') - - async function handleSubmit() { + async function handleSubmit(data) { try { if (isLogin) { - await login({ email: emailFieldVal, password: passwordFieldVal }) + await login(data) onLoginSuccess() } else { - await signup({ email: emailFieldVal, password: passwordFieldVal }) + await signup(data) if (isEmailVerificationRequired) { showEmailVerificationPending() } else { - await login({ email: emailFieldVal, password: passwordFieldVal }) + await login(data) onLoginSuccess() } } - setEmailFieldVal('') - setPasswordFieldVal('') } catch (err: unknown) { onError(err as Error) } @@ -41,9 +35,5 @@ export function useEmail({ return { handleSubmit, - emailFieldVal, - passwordFieldVal, - setEmailFieldVal, - setPasswordFieldVal, } } diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts index 3c27fc84a..247c1faeb 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/internal/usernameAndPassword/useUsernameAndPassword.ts @@ -1,4 +1,3 @@ -import { useState } from 'react' import signup from '../../../signup' import login from '../../../login' @@ -11,21 +10,13 @@ export function useUsernameAndPassword({ onSuccess: () => void isLogin: boolean }) { - const [usernameFieldVal, setUsernameFieldVal] = useState('') - const [passwordFieldVal, setPasswordFieldVal] = useState('') - - async function handleSubmit() { + async function handleSubmit(data) { try { if (!isLogin) { - await signup({ - username: usernameFieldVal, - password: passwordFieldVal, - }) + await signup(data) } - await login(usernameFieldVal, passwordFieldVal) + await login(data.username, data.password) - setUsernameFieldVal('') - setPasswordFieldVal('') onSuccess() } catch (err: unknown) { onError(err as Error) @@ -34,9 +25,5 @@ export function useUsernameAndPassword({ return { handleSubmit, - usernameFieldVal, - passwordFieldVal, - setUsernameFieldVal, - setPasswordFieldVal, } } diff --git a/waspc/data/Generator/templates/react-app/src/auth/forms/types.ts b/waspc/data/Generator/templates/react-app/src/auth/forms/types.ts index b960c8818..2db931584 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/forms/types.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/forms/types.ts @@ -1,5 +1,7 @@ {{={= =}=}} import { createTheme } from '@stitches/react' +import { UseFormReturn, RegisterOptions } from 'react-hook-form' +import type { LoginSignupFormFields } from './internal/common/LoginSignupForm' export enum State { Login = 'login', @@ -21,3 +23,23 @@ export type ErrorMessage = { title: string description?: string } + +export type FormState = { + isLoading: boolean +} + +export type AdditionalSignupFieldRenderFn = ( + hookForm: UseFormReturn, + formState: FormState +) => React.ReactNode + +export type AdditionalSignupField = { + name: string + label: string + type: 'input' | 'textarea' + validations?: RegisterOptions +} + +export type AdditionalSignupFields = + | (AdditionalSignupField | AdditionalSignupFieldRenderFn)[] + | AdditionalSignupFieldRenderFn diff --git a/waspc/data/Generator/templates/react-app/src/stitches.config.js b/waspc/data/Generator/templates/react-app/src/stitches.config.js index 741c20560..c1d600a3f 100644 --- a/waspc/data/Generator/templates/react-app/src/stitches.config.js +++ b/waspc/data/Generator/templates/react-app/src/stitches.config.js @@ -12,6 +12,7 @@ export const { gray500: 'gainsboro', gray400: '#f0f0f0', red: '#FED7D7', + darkRed: '#fa3838', green: '#C6F6D5', brand: '$waspYellow', @@ -23,6 +24,7 @@ export const { submitButtonText: 'black', + formErrorText: '$darkRed', }, fontSizes: { sm: '0.875rem' diff --git a/waspc/data/Generator/templates/server/src/auth/index.ts b/waspc/data/Generator/templates/server/src/auth/index.ts new file mode 100644 index 000000000..374824b69 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/index.ts @@ -0,0 +1,7 @@ +{{={= =}=}} +{=# isEmailAuthEnabled =} +export { defineAdditionalSignupFields } from './providers/email/types.js'; +{=/ isEmailAuthEnabled =} +{=# isLocalAuthEnabled =} +export { defineAdditionalSignupFields } from './providers/local/types.js'; +{=/ isLocalAuthEnabled =} \ No newline at end of file diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index 787035420..ef00d6cba 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -11,6 +11,7 @@ import { isEmailResendAllowed, } from "../../utils.js"; import { GetVerificationEmailContentFn } from './types.js'; +import { validateAndGetAdditionalFields } from '../../utils.js' export function getSignupRoute({ fromField, @@ -41,8 +42,11 @@ export function getSignupRoute({ } await deleteUser(existingUser); } + + const additionalFields = await validateAndGetAdditionalFields(userFields); const user = await createUser({ + ...additionalFields, email: userFields.email, password: userFields.password, }); diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts index f213c80e9..d1a7bc4a1 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts @@ -1,3 +1,5 @@ +import { createDefineAdditionalSignupFieldsFn } from '../types.js' + export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent; export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent; @@ -11,3 +13,5 @@ type EmailContent = { export const tokenVerificationErrors = { TokenExpiredError: 'TokenExpiredError', }; + +export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"email" | "password">() diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts index f1381a371..b7427d323 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts @@ -1,11 +1,15 @@ {{={= =}=}} import { handleRejection } from '../../../utils.js' import { createUser } from '../../utils.js' +import { validateAndGetAdditionalFields } from '../../utils.js' export default handleRejection(async (req, res) => { const userFields = req.body || {} + const additionalFields = await validateAndGetAdditionalFields(userFields) + await createUser({ + ...additionalFields, username: userFields.username, password: userFields.password, }) diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts new file mode 100644 index 000000000..5d72e7d7a --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts @@ -0,0 +1,3 @@ +import { createDefineAdditionalSignupFieldsFn } from '../types.js' + +export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"username" | "password">() diff --git a/waspc/data/Generator/templates/server/src/auth/providers/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/types.ts index 0718ea5b3..0889298c0 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/types.ts @@ -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 = { // 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 function createDefineAdditionalSignupFieldsFn< + // Wasp already includes these fields in the signup process + ExistingFields extends keyof User, + PossibleAdditionalFields = Expand< + Partial> + > +>() { + return function defineFields(config: { + [key in keyof PossibleAdditionalFields]: FieldGetter< + PossibleAdditionalFields[key] + > + }) { + return config + } +} + +type FieldGetter = ( + data: { [key: string]: unknown } +) => Promise | T | undefined diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index 75df7bb22..92e0aec67 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -12,6 +12,19 @@ import { isValidEmail } from '../core/auth/validators.js' import { emailSender } from '../email/index.js'; import { Email } from '../email/core/types.js'; {=/ 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> +> +{=/ additionalSignupFields.isDefined =} type {= userEntityUpper =}Id = {= userEntityUpper =}['id'] @@ -218,4 +231,23 @@ function rethrowPossiblePrismaError(e: unknown): void { function throwValidationError(message: string): void { throw new HttpError(422, 'Validation failed', { message }) -} \ No newline at end of file +} + +export async function validateAndGetAdditionalFields(data: { + [key: string]: unknown +}) { + const { + password: _password, + ...sanitizedData + } = data; + const result: Record = {}; + for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) { + try { + const value = await getFieldValue(sanitizedData) + result[field] = value + } catch (e) { + throwValidationError(e.message) + } + } + return result; +} diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums index 85a718f9d..7dd547ace 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums @@ -270,7 +270,7 @@ "file", "web-app/package.json" ], - "ee9766b7c88b3d4ac36b1dd4b3237ea750e4c26ad27bcd18006225707d042e18" + "8bacfb3d4e24886405c2a8fa94be0be7d3ec4b882063e5f22667893811cd4371" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json index 5d61d8e2b..8dcfd5950 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json @@ -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"}]}} \ No newline at end of file +{"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"}]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json index c3a70d5bf..fda96740a 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json @@ -18,6 +18,7 @@ "mitt": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", "react-router-dom": "^5.3.3", "superjson": "^1.12.2" }, diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp index fa019643a..9e30d3235 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/main.wasp @@ -1,7 +1,7 @@ app waspBuild { db: { system: PostgreSQL }, wasp: { - version: "^0.11.3" + version: "^0.11.4" }, title: "waspBuild" } diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums index 149ef52cf..f63add315 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums @@ -284,7 +284,7 @@ "file", "web-app/package.json" ], - "8a2249588d7cf9ac7c6c8cb979727c264446581f60c7062e5852aef4c1d8a675" + "c2b7000a7380cce059cdafe67fa755a8e5f4d1de5e13eb11e545a3ee4d32db0d" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json index 5d61d8e2b..8dcfd5950 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json @@ -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"}]}} \ No newline at end of file +{"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"}]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json index d82402389..023f3c22a 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json @@ -18,6 +18,7 @@ "mitt": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", "react-router-dom": "^5.3.3", "superjson": "^1.12.2" }, diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp index 7658e970e..bde6ce480 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/main.wasp @@ -1,6 +1,6 @@ app waspCompile { wasp: { - version: "^0.11.3" + version: "^0.11.4" }, title: "waspCompile" } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums index 8d3076614..d7c3eeff8 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums @@ -179,14 +179,14 @@ "file", "server/src/auth/providers/types.ts" ], - "9859bf91e0abe0aaadf7b8c74c573607f6085a5414071836cef6ba84b2bebb69" + "323555d76755fe32b21084f063caf931faabcb5937c279cc706bbecad3361d43" ], [ [ "file", "server/src/auth/utils.ts" ], - "b611de9a6b546f6f1cec4497a4cb525150399131dfba32b53ba34afb04e62e96" + "1cfb0c8095ba0ed7686229b22e8a9edd971526d50660f88d2cbb988af9b5af30" ], [ [ @@ -578,7 +578,7 @@ "file", "web-app/package.json" ], - "fc5d4d8e4a3ed36972eac6891122dfd3d8edb4074a8df041162dcb38bb6f6d2d" + "7857f3a9fc85cd39f16dbd9362bccc5e5662b5b216da45b843ebca6bba4142b5" ], [ [ @@ -655,7 +655,7 @@ "file", "web-app/src/auth/forms/Auth.tsx" ], - "d40cf940a499fdd4b137dcf9f3cd4fbe0bbab4b7c44eb7819b41daeaa861050b" + "fc6c204f73999f556eab441772e66dd4dbd433bd69aa2c9e64e713fb2921a886" ], [ [ @@ -669,14 +669,14 @@ "file", "web-app/src/auth/forms/Signup.tsx" ], - "cacd5348e84d42bc142f6c6e00051a8e86f6b17eb037b2772772f073925bf570" + "a38124a9a250a603ef6d04dbcb46c04084ace676e9de5fc31f229405d87f47d4" ], [ [ "file", "web-app/src/auth/forms/internal/Form.tsx" ], - "ce6b409fda73d88e762b27aef7038618961e61e6ef8e5f66912325ab88a8223e" + "b9b21954b919f173b751c0078aed8303cc15d88df9e9874228efcae7976f26cd" ], [ [ @@ -690,7 +690,7 @@ "file", "web-app/src/auth/forms/internal/common/LoginSignupForm.tsx" ], - "c92bab325f51159c3d1bb285c7e807acbb85069c5b16afc3a35fbd0121c91b6a" + "f211f57dca3f10f08a3e618d27bf8006d26f6832bb7e40f8a22ae44f2d42531e" ], [ [ @@ -711,7 +711,7 @@ "file", "web-app/src/auth/forms/types.ts" ], - "c4066fbd39ec20a3d43be9f9d5762d555dbc006057579ac168a67b2678918a13" + "992ca4b2c8e30536636143c556e6bdcc5d5d0d86c1eb2e119171e25d5c33b4e3" ], [ [ @@ -935,7 +935,7 @@ "file", "web-app/src/stitches.config.js" ], - "7de37836b80021870f286ff14d275e2ca7a1c2aa113ba5a5624ed0c77e178f76" + "f238234a9db89d6a34c7a8c7c948a58c011da8e167ff94d72e7c6808beb4e177" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json index 70b6f0f64..8063b55af 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/installedFullStackNpmDependencies.json @@ -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"}]}} \ No newline at end of file +{"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"}]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts index 0718ea5b3..0889298c0 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts @@ -1,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 = { // 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 function createDefineAdditionalSignupFieldsFn< + // Wasp already includes these fields in the signup process + ExistingFields extends keyof User, + PossibleAdditionalFields = Expand< + Partial> + > +>() { + return function defineFields(config: { + [key in keyof PossibleAdditionalFields]: FieldGetter< + PossibleAdditionalFields[key] + > + }) { + return config + } +} + +type FieldGetter = ( + data: { [key: string]: unknown } +) => Promise | T | undefined diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts index d6a5d352f..3006cb231 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts @@ -7,6 +7,11 @@ import { type User } from '../entities/index.js' import waspServerConfig from '../config.js'; import { type Prisma } from '@prisma/client'; +import { createDefineAdditionalSignupFieldsFn } from './providers/types.js' +const _waspAdditionalSignupFieldsConfig = {} as ReturnType< + ReturnType> +> + type UserId = User['id'] export const contextWithUserEntity = { @@ -73,4 +78,23 @@ function rethrowPossiblePrismaError(e: unknown): void { function throwValidationError(message: string): void { throw new HttpError(422, 'Validation failed', { message }) -} \ No newline at end of file +} + +export async function validateAndGetAdditionalFields(data: { + [key: string]: unknown +}) { + const { + password: _password, + ...sanitizedData + } = data; + const result: Record = {}; + for (const [field, getFieldValue] of Object.entries(_waspAdditionalSignupFieldsConfig)) { + try { + const value = await getFieldValue(sanitizedData) + result[field] = value + } catch (e) { + throwValidationError(e.message) + } + } + return result; +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/package.json index 7afc9b78b..06cc393b8 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/package.json @@ -19,6 +19,7 @@ "mitt": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", "react-redux": "^7.1.3", "react-router-dom": "^5.3.3", "redux": "^4.0.5", diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx index b695a6a2e..95a337f43 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx @@ -6,6 +6,7 @@ import { type State, type CustomizationOptions, type ErrorMessage, + type AdditionalSignupFields, } from './types' import { LoginSignupForm } from './internal/common/LoginSignupForm' import { MessageError, MessageSuccess } from './internal/Message' @@ -33,9 +34,11 @@ export const AuthContext = createContext({ setSuccessMessage: (successMessage: string | null) => {}, }) -function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: { +function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: { state: State; -} & CustomizationOptions) { +} & CustomizationOptions & { + additionalSignupFields?: AdditionalSignupFields; +}) { const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -71,6 +74,7 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: { )} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx index b48cf4e91..66ffab450 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx @@ -1,17 +1,23 @@ import Auth from './Auth' -import { type CustomizationOptions, State } from './types' +import { + type CustomizationOptions, + type AdditionalSignupFields, + State, +} from './types' export function SignupForm({ appearance, logo, socialLayout, -}: CustomizationOptions) { + additionalFields, +}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) { return ( ) } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/Form.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/Form.tsx index 2095e48b7..24d6c586d 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/Form.tsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/Form.tsx @@ -14,9 +14,10 @@ export const FormLabel = styled('label', { display: 'block', fontSize: '$sm', fontWeight: '500', + marginBottom: '0.5rem', }) -export const FormInput = styled('input', { +const commonInputStyles = { display: 'block', lineHeight: '1.5rem', fontSize: '$sm', @@ -44,7 +45,18 @@ export const FormInput = styled('input', { 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', }) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/common/LoginSignupForm.tsx index 1bf119786..39b7068ca 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/internal/common/LoginSignupForm.tsx @@ -1,9 +1,22 @@ -import { useContext, type FormEvent } from 'react' -import { styled } from '../../../../stitches.config' -import config from '../../../../config.js' +import { useContext } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' 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 { SocialButton } from '../social/SocialButton' @@ -46,12 +59,18 @@ const SocialAuthButtons = styled('div', { }) const googleSignInUrl = `${config.apiUrl}/auth/google/login` +export type LoginSignupFormFields = { + [key: string]: string; +} + export const LoginSignupForm = ({ state, socialButtonsDirection = 'horizontal', + additionalSignupFields, }: { - state: 'login' | 'signup', - socialButtonsDirection?: 'horizontal' | 'vertical'; + state: 'login' | 'signup' + socialButtonsDirection?: 'horizontal' | 'vertical' + additionalSignupFields?: AdditionalSignupFields }) => { const { isLoading, @@ -59,7 +78,10 @@ export const LoginSignupForm = ({ setSuccessMessage, setIsLoading, } = useContext(AuthContext) - const cta = state === 'login' ? 'Log in' : 'Sign up'; + const isLogin = state === 'login' + const cta = isLogin ? 'Log in' : 'Sign up'; + const hookForm = useForm() + const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm return (<> @@ -71,3 +93,76 @@ export const LoginSignupForm = ({ ) } + +function AdditionalFormFields({ + hookForm, + formState: { isLoading }, + additionalSignupFields, +}: { + hookForm: UseFormReturn; + formState: FormState; + additionalSignupFields: AdditionalSignupFields; +}) { + const { + register, + formState: { errors }, + } = hookForm; + + function renderField>( + field: AdditionalSignupField, + // Ideally we would use ComponentType here, but it doesn't work with react-hook-form + Component: any, + props?: React.ComponentProps + ) { + return ( + + {field.label} + + {errors[field.name] && ( + {errors[field.name].message} + )} + + ); + } + + 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(field, FormInput, { + type: 'text', + }) + case 'textarea': + return renderField(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' +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts index 296fe1a98..14d61ad51 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts @@ -1,4 +1,6 @@ import { createTheme } from '@stitches/react' +import { UseFormReturn, RegisterOptions } from 'react-hook-form' +import type { LoginSignupFormFields } from './internal/common/LoginSignupForm' export enum State { Login = 'login', @@ -15,3 +17,23 @@ export type ErrorMessage = { title: string description?: string } + +export type FormState = { + isLoading: boolean +} + +export type AdditionalSignupFieldRenderFn = ( + hookForm: UseFormReturn, + formState: FormState +) => React.ReactNode + +export type AdditionalSignupField = { + name: string + label: string + type: 'input' | 'textarea' + validations?: RegisterOptions +} + +export type AdditionalSignupFields = + | (AdditionalSignupField | AdditionalSignupFieldRenderFn)[] + | AdditionalSignupFieldRenderFn diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/stitches.config.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/stitches.config.js index 741c20560..c1d600a3f 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/stitches.config.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/stitches.config.js @@ -12,6 +12,7 @@ export const { gray500: 'gainsboro', gray400: '#f0f0f0', red: '#FED7D7', + darkRed: '#fa3838', green: '#C6F6D5', brand: '$waspYellow', @@ -23,6 +24,7 @@ export const { submitButtonText: 'black', + formErrorText: '$darkRed', }, fontSizes: { sm: '0.875rem' diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp index dff972234..d807803fe 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp @@ -1,7 +1,7 @@ app waspComplexTest { db: { system: PostgreSQL }, wasp: { - version: "^0.11.3" + version: "^0.11.4" }, auth: { userEntity: User, diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index 6a252b8a7..d42f9e854 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -326,7 +326,7 @@ "file", "web-app/package.json" ], - "57da965d01f5d39d74c6ca91f00d84ba5b6c78660ee837266b2622e410e4fd8e" + "adcc3a24462553bd66d4c14f43cd351512d28c249d96d3cd3e1e6dad834770fd" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json index e243a8863..d84d0e9a2 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json @@ -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"}]}} \ No newline at end of file +{"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"}]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json index adb646cfb..91bf941c1 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json @@ -18,6 +18,7 @@ "mitt": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", "react-router-dom": "^5.3.3", "superjson": "^1.12.2" }, diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/main.wasp b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/main.wasp index 910e05704..c86e327e0 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/main.wasp +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/main.wasp @@ -1,7 +1,7 @@ app waspJob { db: { system: PostgreSQL }, wasp: { - version: "^0.11.3" + version: "^0.11.4" }, title: "waspJob" } diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums index 2e41fb6ba..7e1b05654 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums @@ -284,7 +284,7 @@ "file", "web-app/package.json" ], - "7e237189e89ac549b485ffef329aaa6699c874fd16b8e890f37f412ad7715219" + "fd7ca891ab5d232690015d34110d34a180906e1bb7c37e2432ebad2356b860a3" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json index 5d61d8e2b..8dcfd5950 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json @@ -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"}]}} \ No newline at end of file +{"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"}]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json index c8daa561a..ca85f83a3 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json @@ -18,6 +18,7 @@ "mitt": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", "react-router-dom": "^5.3.3", "superjson": "^1.12.2" }, diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/main.wasp b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/main.wasp index bed93900a..1e01f0578 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/main.wasp +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/main.wasp @@ -1,6 +1,6 @@ app waspMigrate { wasp: { - version: "^0.11.3" + version: "^0.11.4" }, title: "waspMigrate" } diff --git a/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp b/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp index 9fc57e4ae..82426869b 100644 --- a/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp +++ b/waspc/e2e-test/test-outputs/waspNew-golden/waspNew/main.wasp @@ -1,6 +1,6 @@ app waspNew { wasp: { - version: "^0.11.3" + version: "^0.11.4" }, title: "waspNew" } diff --git a/waspc/examples/crud-testing/main.wasp b/waspc/examples/crud-testing/main.wasp index 0b0c281a9..468591b18 100644 --- a/waspc/examples/crud-testing/main.wasp +++ b/waspc/examples/crud-testing/main.wasp @@ -12,7 +12,13 @@ app crudTesting { usernameAndPassword: {}, }, onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth.js", + }, }, + dependencies: [ + ("zod", "^3.22.2") + ], db: { system: PostgreSQL } @@ -44,6 +50,7 @@ entity User {=psl id Int @id @default(autoincrement()) username String @unique password String + address String? tasks Task[] psl=} diff --git a/waspc/examples/crud-testing/migrations/20230816101428_add_address/migration.sql b/waspc/examples/crud-testing/migrations/20230816101428_add_address/migration.sql new file mode 100644 index 000000000..dae79d49f --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20230816101428_add_address/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "address" TEXT; diff --git a/waspc/examples/crud-testing/prettier.config.js b/waspc/examples/crud-testing/prettier.config.js new file mode 100644 index 000000000..c9385050c --- /dev/null +++ b/waspc/examples/crud-testing/prettier.config.js @@ -0,0 +1,5 @@ +module.exports = { + trailingComma: 'es5', + semi: false, + singleQuote: true, +} \ No newline at end of file diff --git a/waspc/examples/crud-testing/src/client/MainPage.tsx b/waspc/examples/crud-testing/src/client/MainPage.tsx index b6bd125d9..96d604d31 100644 --- a/waspc/examples/crud-testing/src/client/MainPage.tsx +++ b/waspc/examples/crud-testing/src/client/MainPage.tsx @@ -2,6 +2,7 @@ import "./Main.css"; import React, { useState } from "react"; import { Link, routes } from "@wasp/router"; +import logout from "@wasp/auth/logout"; import { tasks as tasksCrud } from "@wasp/crud/tasks"; import { User } from "@wasp/entities"; @@ -45,7 +46,7 @@ const MainPage = ({ user }: { user: User }) => { setEditTaskTitle(""); } - function handleStartEditing(task: Task) { + function handleStartEditing(task: { id: number; title: string }) { setIsEditing(task.id); setEditTaskTitle(task.title); } @@ -122,6 +123,7 @@ const MainPage = ({ user }: { user: User }) => { Create task + ); diff --git a/waspc/examples/crud-testing/src/client/SignupPage.tsx b/waspc/examples/crud-testing/src/client/SignupPage.tsx index fa2654387..cd408f1c4 100644 --- a/waspc/examples/crud-testing/src/client/SignupPage.tsx +++ b/waspc/examples/crud-testing/src/client/SignupPage.tsx @@ -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 = () => { return ( -
-
-

Signup

- -
-
- ); -}; + + ) +} diff --git a/waspc/examples/crud-testing/src/server/auth.ts b/waspc/examples/crud-testing/src/server/auth.ts new file mode 100644 index 000000000..254c51f91 --- /dev/null +++ b/waspc/examples/crud-testing/src/server/auth.ts @@ -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 + }, +}) diff --git a/waspc/examples/crud-testing/src/server/auth_simple.js b/waspc/examples/crud-testing/src/server/auth_simple.js new file mode 100644 index 000000000..52d6aad97 --- /dev/null +++ b/waspc/examples/crud-testing/src/server/auth_simple.js @@ -0,0 +1,5 @@ +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: (data) => data.address, +}) diff --git a/waspc/examples/todoApp/migrations/20230816133128_add_address/migration.sql b/waspc/examples/todoApp/migrations/20230816133128_add_address/migration.sql new file mode 100644 index 000000000..a1115f717 --- /dev/null +++ b/waspc/examples/todoApp/migrations/20230816133128_add_address/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "address" TEXT; diff --git a/waspc/examples/todoApp/src/client/Todo.test.tsx b/waspc/examples/todoApp/src/client/Todo.test.tsx index eb404a953..9746808f9 100644 --- a/waspc/examples/todoApp/src/client/Todo.test.tsx +++ b/waspc/examples/todoApp/src/client/Todo.test.tsx @@ -7,6 +7,7 @@ import getDate from '@wasp/queries/getDate' import Todo, { areThereAnyTasks } from './Todo' import { App } from './App' import { getMe } from '@wasp/auth/useAuth' +import type { User } from '@wasp/auth/types' test('areThereAnyTasks', () => { expect(areThereAnyTasks([])).toBe(false) @@ -38,8 +39,9 @@ const mockUser = { email: 'elon@tesla.com', isEmailVerified: false, emailVerificationSentAt: null, - passwordResetSentAt: null -} + passwordResetSentAt: null, + address: null, +} satisfies User test('handles multiple mock data sources', async () => { mockQuery(getMe, mockUser) diff --git a/waspc/examples/todoApp/src/client/pages/auth/Signup.tsx b/waspc/examples/todoApp/src/client/pages/auth/Signup.tsx index 7ceb78b63..ac4d73a4b 100644 --- a/waspc/examples/todoApp/src/client/pages/auth/Signup.tsx +++ b/waspc/examples/todoApp/src/client/pages/auth/Signup.tsx @@ -7,6 +7,7 @@ import { getTotalTaskCountMessage } from './helpers' import appearance from './appearance' import todoLogo from '../../todoLogo.png' +import { FormItemGroup } from '@wasp/auth/forms/internal/Form' const Signup = () => { const { data: numTasks } = useQuery(getNumTasks) @@ -20,6 +21,22 @@ const Signup = () => { appearance={appearance} logo={todoLogo} socialLayout="horizontal" + additionalFields={[ + { + name: 'address', + type: 'input', + label: 'Address', + validations: { + required: 'Address is required', + }, + }, + () => ( + + 👉 Don't forget to press the button below to submit the + form. + + ), + ]} />
diff --git a/waspc/examples/todoApp/src/server/auth/signup.ts b/waspc/examples/todoApp/src/server/auth/signup.ts new file mode 100644 index 000000000..d2e9414db --- /dev/null +++ b/waspc/examples/todoApp/src/server/auth/signup.ts @@ -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 + }, +}) diff --git a/waspc/examples/todoApp/todoApp.wasp b/waspc/examples/todoApp/todoApp.wasp index 6e2683918..65a5c1f71 100644 --- a/waspc/examples/todoApp/todoApp.wasp +++ b/waspc/examples/todoApp/todoApp.wasp @@ -41,6 +41,9 @@ app todoApp { allowUnverifiedLogin: false, }, }, + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, onAuthFailedRedirectTo: "/login", onAuthSucceededRedirectTo: "/profile" }, @@ -79,6 +82,7 @@ entity User {=psl externalAuthAssociations SocialLogin[] // Business logic tasks Task[] + address String? psl=} entity SocialLogin {=psl diff --git a/waspc/src/Wasp/AppSpec/App/Auth.hs b/waspc/src/Wasp/AppSpec/App/Auth.hs index 1ba4c8e01..f1f7e1df8 100644 --- a/waspc/src/Wasp/AppSpec/App/Auth.hs +++ b/waspc/src/Wasp/AppSpec/App/Auth.hs @@ -6,6 +6,7 @@ module Wasp.AppSpec.App.Auth AuthMethods (..), ExternalAuthConfig (..), EmailAuthConfig (..), + SignupOptions (..), usernameAndPasswordConfig, isUsernameAndPasswordAuthEnabled, isExternalAuthEnabled, @@ -29,6 +30,7 @@ data Auth = Auth { userEntity :: Ref Entity, externalAuthEntity :: Maybe (Ref Entity), methods :: AuthMethods, + signup :: Maybe SignupOptions, onAuthFailedRedirectTo :: String, onAuthSucceededRedirectTo :: Maybe String } @@ -62,6 +64,11 @@ data EmailAuthConfig = EmailAuthConfig } deriving (Show, Eq, Data) +data SignupOptions = SignupOptions + { additionalFields :: Maybe ExtImport + } + deriving (Show, Eq, Data) + usernameAndPasswordConfig :: UsernameAndPasswordConfig usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index c4178bde0..a5df64833 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -390,7 +390,11 @@ genExportedTypesDir spec = [ C.mkTmplFdWithData [relfile|src/types/index.ts|] (Just tmplData) ] where - tmplData = object ["isExternalAuthEnabled" .= isExternalAuthEnabled, "isEmailAuthEnabled" .= isEmailAuthEnabled] + tmplData = + object + [ "isExternalAuthEnabled" .= isExternalAuthEnabled, + "isEmailAuthEnabled" .= isEmailAuthEnabled + ] isExternalAuthEnabled = AS.App.Auth.isExternalAuthEnabled <$> maybeAuth isEmailAuthEnabled = AS.App.Auth.isEmailAuthEnabled <$> maybeAuth maybeAuth = AS.App.auth $ snd $ getApp spec diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs index 0d6fb2d1d..02ef41587 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Auth/LocalAuthG.hs @@ -28,9 +28,12 @@ genLocalAuth auth sequence [ genLoginRoute auth, genSignupRoute auth, - genLocalAuthConfig + genLocalAuthConfig, + genFileCopy [relfile|auth/providers/local/types.ts|] ] | otherwise = return [] + where + genFileCopy = return . C.mkSrcTmplFd genLocalAuthConfig :: Generator FileDraft genLocalAuthConfig = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index 3ff347651..6e49e2f66 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -10,6 +10,7 @@ import StrongPath Path', Rel, reldir, + reldirP, relfile, (), ) @@ -29,6 +30,7 @@ import Wasp.Generator.ServerGenerator.Auth.EmailAuthG (genEmailAuth) import Wasp.Generator.ServerGenerator.Auth.LocalAuthG (genLocalAuth) import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (genOAuthAuth) import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson) import Wasp.Util ((<++>)) import qualified Wasp.Util as Util @@ -45,6 +47,7 @@ genAuth spec = case maybeAuth of genProvidersIndex auth, genFileCopy [relfile|auth/providers/types.ts|] ] + <++> genIndexTs auth <++> genLocalAuth auth <++> genOAuthAuth spec auth <++> genEmailAuth spec auth @@ -125,12 +128,30 @@ genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplDat "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), "failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo 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 = [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 auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator.hs index 009a93f17..d7050fa43 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator.hs @@ -136,7 +136,9 @@ npmDepsForWasp spec = -- https://github.com/wasp-lang/wasp/pull/962/ for details). ("@prisma/client", show prismaVersion), ("superjson", "^1.12.2"), - ("mitt", "3.0.0") + ("mitt", "3.0.0"), + -- Used for Auth UI + ("react-hook-form", "^7.45.4") ] ++ depsRequiredForAuth spec ++ depsRequiredByTailwind spec diff --git a/waspc/test/AnalyzerTest.hs b/waspc/test/AnalyzerTest.hs index 7b9d9ae97..31560fbac 100644 --- a/waspc/test/AnalyzerTest.hs +++ b/waspc/test/AnalyzerTest.hs @@ -50,6 +50,9 @@ spec_Analyzer = do " userEntity: User,", " methods: { usernameAndPassword: {} },", " onAuthFailedRedirectTo: \"/\",", + " signup: {", + " additionalFields: import { fields } from \"@server/auth/signup.js\",", + " },", " },", " dependencies: [", " (\"redux\", \"^4.0.5\")", @@ -135,6 +138,12 @@ spec_Analyzer = do Auth.Auth { Auth.userEntity = Ref "User" :: Ref Entity, Auth.externalAuthEntity = Nothing, + Auth.signup = + Just $ + Auth.SignupOptions + { Auth.additionalFields = + Just $ ExtImport (ExtImportField "fields") (fromJust $ SP.parseRelFileP "auth/signup.js") + }, Auth.methods = Auth.AuthMethods { Auth.usernameAndPassword = Just Auth.usernameAndPasswordConfig, diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index 014c364bd..3ecc97c4d 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -6,7 +6,7 @@ cabal-version: 2.4 -- Consider using hpack, or maybe even hpack-dhall. name: waspc -version: 0.11.3 +version: 0.11.4 description: Please see the README on GitHub at homepage: https://github.com/wasp-lang/wasp/waspc#readme bug-reports: https://github.com/wasp-lang/wasp/issues diff --git a/web/docs/auth/overview.md b/web/docs/auth/overview.md index d623a7493..03854331e 100644 --- a/web/docs/auth/overview.md +++ b/web/docs/auth/overview.md @@ -7,7 +7,7 @@ import { Required } from "@site/src/components/Required"; Auth is an essential piece of any serious application. Coincidentally, Wasp provides authentication and authorization support out of the box 🙃. -Enabling auth for your app is optional and can be done by configuring the `auth` field of the `app` declaration. +Enabling auth for your app is optional and can be done by configuring the `auth` field of the `app` declaration. @@ -31,6 +31,7 @@ app MyApp { //... ``` + @@ -53,18 +54,18 @@ app MyApp { //... ``` + - Read more about the `auth` field options in the [API Reference](#api-reference) section. +Read more about the `auth` field options in the [API Reference](#api-reference) section. We will provide a quick overview of auth in Wasp and link to more detailed documentation for each auth method. - ## Available auth methods Wasp supports the following auth methods: @@ -73,7 +74,7 @@ Wasp supports the following auth methods: Let's say we enabled the [Username & password](/docs/auth/username-and-pass) authentication. -We get an auth backend with signup and login endpoints. We also get the `user` object in our [Operations](/docs/data-model/operations/overview) and we can decide what to do based on whether the user is logged in or not. +We get an auth backend with signup and login endpoints. We also get the `user` object in our [Operations](/docs/data-model/operations/overview) and we can decide what to do based on whether the user is logged in or not. We would also get the [Auth UI](/docs/auth/ui) generated for us. We can set up our login and signup pages where our users can **create their account** and **login**. We can then protect certain pages by setting `authRequired: true` for them. This will make sure that only logged-in users can access them. @@ -94,6 +95,7 @@ page MainPage { authRequired: true } ``` + @@ -103,6 +105,7 @@ page MainPage { authRequired: true } ``` + @@ -123,11 +126,10 @@ We provide an action for logging out the user. Here's how you can use it: import logout from '@wasp/auth/logout' const LogoutButton = () => { - return ( - - ) + return } ``` + @@ -135,11 +137,10 @@ const LogoutButton = () => { import logout from '@wasp/auth/logout' const LogoutButton = () => { - return ( - - ) + return } ``` + @@ -150,7 +151,8 @@ You can get access to the `user` object both in the backend and on the frontend. ### On the client There are two ways to access the `user` object on the client: -- the `user` prop + +- the `user` prop - the `useAuth` hook #### Using the `user` prop @@ -170,8 +172,8 @@ page AccountPage { ``` ```jsx title="client/pages/Account.jsx" -import Button from './Button'; -import logout from '@wasp/auth/logout'; +import Button from './Button' +import logout from '@wasp/auth/logout' const AccountPage = ({ user }) => { return ( @@ -179,11 +181,12 @@ const AccountPage = ({ user }) => { {JSON.stringify(user, null, 2)} - ); -}; + ) +} -export default AccountPage; +export default AccountPage ``` + @@ -197,9 +200,9 @@ page AccountPage { ``` ```tsx title="client/pages/Account.tsx" -import type { User } from '@wasp/entities'; -import Button from './Button'; -import logout from '@wasp/auth/logout'; +import type { User } from '@wasp/entities' +import Button from './Button' +import logout from '@wasp/auth/logout' const AccountPage = ({ user }: { user: User }) => { return ( @@ -207,11 +210,12 @@ const AccountPage = ({ user }: { user: User }) => { {JSON.stringify(user, null, 2)} - ); -}; + ) +} -export default AccountPage; +export default AccountPage ``` + @@ -236,7 +240,8 @@ export function Main() { if (!user) { return ( - Please login or sign up. + Please login or{' '} + sign up. ) } else { @@ -244,11 +249,12 @@ export function Main() { <> - < /> + ) } } ``` + @@ -277,6 +283,7 @@ export function Main() { } } ``` + @@ -306,23 +313,27 @@ export const createTask = async (task, context) => { data: { description: task.description, user: { - connect: { id: context.user.id } - } - } + connect: { id: context.user.id }, + }, + }, }) } ``` + ```ts title="src/server/actions.ts" -import type { Task } from "@wasp/entities" -import type { CreateTask } from "@wasp/actions/types" +import type { Task } from '@wasp/entities' +import type { CreateTask } from '@wasp/actions/types' import HttpError from '@wasp/core/HttpError.js' -type CreateTaskPayload = Pick +type CreateTaskPayload = Pick -export const createTask: CreateTask = async (args, context) => { +export const createTask: CreateTask = async ( + args, + context +) => { if (!context.user) { throw new HttpError(403) } @@ -332,12 +343,13 @@ export const createTask: CreateTask = async (args, cont data: { description: args.description, user: { - connect: { id: context.user.id } - } - } + connect: { id: context.user.id }, + }, + }, }) } ``` + @@ -360,31 +372,36 @@ export const updatePassword = async (args, context) => { return context.entities.User.update({ where: { id: args.userId }, data: { - password: 'New pwd which will be hashed automatically!' - } + password: 'New pwd which will be hashed automatically!', + }, }) } ``` + ```ts title="src/server/actions.ts" -import type { UpdatePassword } from "@wasp/actions/types" -import type { User } from "@wasp/entities" +import type { UpdatePassword } from '@wasp/actions/types' +import type { User } from '@wasp/entities' type UpdatePasswordPayload = { - userId: User["id"] + userId: User['id'] } -export const updatePassword: UpdatePassword = async (args, context) => { +export const updatePassword: UpdatePassword< + UpdatePasswordPayload, + User +> = async (args, context) => { return context.entities.User.update({ where: { id: args.userId }, data: { - password: 'New pwd which will be hashed automatically!' - } + password: 'New pwd which will be hashed automatically!', + }, }) } ``` + @@ -397,6 +414,7 @@ Default validations depend on the auth method you use. #### Username & password If you use [Username & password](/docs/auth/username-and-pass) authentication, the default validations are: + - The `username` must not be empty - The `password` must not be empty, have at least 8 characters, and contain a number @@ -405,6 +423,7 @@ Note that `username`s are stored in a **case-sensitive** manner. #### Email If you use [Email](/docs/auth/email) authentication, the default validations are: + - The `email` must not be empty and a valid email address - The `password` must not be empty, have at least 8 characters, and contain a number @@ -427,18 +446,19 @@ To disable/enable default validations, or add your own, modify your custom signu const newUser = context.entities.User.create({ data: { username: args.username, - password: args.password // password hashed automatically by Wasp! 🐝 + password: args.password, // password hashed automatically by Wasp! 🐝 }, _waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true _waspCustomValidations: [ { validates: 'password', message: 'password must contain an uppercase letter', - validator: password => /[A-Z]/.test(password) + validator: (password) => /[A-Z]/.test(password), }, - ] + ], }) ``` + @@ -446,18 +466,19 @@ const newUser = context.entities.User.create({ const newUser = context.entities.User.create({ data: { username: args.username, - password: args.password // password hashed automatically by Wasp! 🐝 + password: args.password, // password hashed automatically by Wasp! 🐝 }, _waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true _waspCustomValidations: [ { validates: 'password', message: 'password must contain an uppercase letter', - validator: password => /[A-Z]/.test(password) + validator: (password) => /[A-Z]/.test(password), }, - ] + ], }) ``` + @@ -469,6 +490,7 @@ The validation process stops on the first `validator` to return false. If enable ::: ### Validation Error Handling + When creating, updating, or deleting entities, you may wish to handle validation errors. Wasp exposes a class called `AuthError` for this purpose. @@ -504,8 +526,399 @@ try { +## Customizing the Signup Process + +Sometimes you want to include **extra fields** in your signup process, like first name and last name. + +In Wasp, in this case: + +- you need to define the fields that you want saved in the database, +- you need to customize the `SignupForm`. + +Other times, you might need to just add some **extra UI** elements to the form, like a checkbox for terms of service. In this case, customizing only the UI components is enough. + +Let's see how to do both. + +### 1. Defining Extra Fields + +If we want to **save** some extra fields in our signup process, we need to tell our app they exist. + +We do that by defining an object where the keys represent the field name, and the values are functions that receive the data sent from the client\* and return the value of the field. + + + +\* We exclude the `password` field from this object to prevent it from being saved as plain-text in the database. The `password` field is handled by Wasp's auth backend. + + +First, we add the `auth.signup.additionalFields` field in our `main.wasp` file: + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + address String? +psl=} +``` + +Then we'll define and export the `fields` object from the `server/auth/signup.js` file: + +```ts title="server/auth/signup.js" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + address String? +psl=} +``` + +Then we'll export the `fields` object from the `server/auth/signup.ts` file: + +```ts title="server/auth/signup.ts" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + + + +Read more about the `fields` object in the [API Reference](#signup-fields-customization). + + +Keep in mind, that these field names need to exist on the `userEntity` you defined in your `main.wasp` file e.g. `address` needs to be a field on the `User` entity. + +The field function will receive the data sent from the client and it needs to return the value that will be saved into the database. If the field is invalid, the function should throw an error. + +:::info Using Validation Libraries + +You can use any validation library you want to validate the fields. For example, you can use `zod` like this: + +
+Click to see the code + + + + +```js title="server/auth/signup.js" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import * as z from 'zod' + +export const fields = defineAdditionalSignupFields({ + address: (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 + }, +}) +``` + + + + +```ts title="server/auth/signup.ts" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' +import * as z from 'zod' + +export const fields = defineAdditionalSignupFields({ + address: (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 + }, +}) +``` + + + +
+ +::: + +Now that we defined the fields, Wasp knows how to: + +1. Validate the data sent from the client +2. Save the data to the database + +Next, let's see how to customize [Auth UI](/docs/auth/ui) to include those fields. + +### 2. Customizing the Signup Component + +:::tip Using Custom Signup Component + +If you are not using Wasp's Auth UI, you can skip this section. Just make sure to include the extra fields in your custom signup form. + +Read more about using the signup actions for: + +- email auth [here](/docs/auth/email#fields-in-the-email-dict) +- username & password auth [here](/docs/auth/username-and-pass#customizing-the-auth-flow) +::: + +If you are using Wasp's Auth UI, you can customize the `SignupForm` component by passing the `additionalFields` prop to it. It can be either a list of extra fields or a render function. + +#### Using a List of Extra Fields + +When you pass in a list of extra fields to the `SignupForm`, they are added to the form one by one, in the order you pass them in. + +Inside the list, there can be either **objects** or **render functions** (you can combine them): + +1. Objects are a simple way to describe new fields you need, but a bit less flexible than render functions. +2. Render functions can be used to render any UI you want, but they require a bit more code. The render functions receive the `react-hook-form` object and the form state object as arguments. + + + + +```jsx title="client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + + + +Read more about the extra fields in the [API Reference](#signupform-customization). + + +#### Using a Single Render Function + +Instead of passing in a list of extra fields, you can pass in a render function which will receive the `react-hook-form` object and the form state object as arguments. What ever the render function returns, will be rendered below the default fields. + + + + +```jsx title="client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { FormItemGroup } from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + const username = form.watch('username') + return ( + username && ( + + Hello there {username} 👋 + + ) + ) + }} + /> + ) +} +``` + + + + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { FormItemGroup } from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + const username = form.watch('username') + return ( + username && ( + + Hello there {username} 👋 + + ) + ) + }} + /> + ) +} +``` + + + + + + +Read more about the render function in the [API Reference](#signupform-customization). + + ## API Reference +### Auth Fields + @@ -521,12 +934,14 @@ try { google: {}, gitHub: {}, }, - onAuthFailedRedirectTo: "/someRoute" + onAuthFailedRedirectTo: "/someRoute", + signup: { ... } } } //... ``` + @@ -543,21 +958,25 @@ app MyApp { google: {}, gitHub: {}, }, - onAuthFailedRedirectTo: "/someRoute" + onAuthFailedRedirectTo: "/someRoute", + signup: { ... } } } //... ``` + `app.auth` is a dictionary with the following fields: #### `userEntity: entity` + The entity representing the user. Its mandatory fields depend on your chosen auth method. #### `externalAuthEntity: entity` + Wasp requires you to set the field `auth.externalAuthEntity` for all authentication methods relying on an external authorizatino provider (e.g., Google). You also need to tweak the Entity referenced by `auth.userEntity`, as shown below. @@ -586,6 +1005,7 @@ entity SocialLogin {=psl @@unique([provider, providerId, userId]) psl=} ``` + @@ -612,6 +1032,7 @@ entity SocialLogin {=psl @@unique([provider, providerId, userId]) psl=} ``` + @@ -622,18 +1043,258 @@ The same `externalAuthEntity` can be used across different social login provider See [Google docs](/docs/auth/social-auth/google) and [GitHub docs](/docs/auth/social-auth/github) for more details. #### `methods: dict` + A dictionary of auth methods enabled for the app. #### `onAuthFailedRedirectTo: String` + The route to which Wasp should redirect unauthenticated user when they try to access a private page (i.e., a page that has `authRequired: true`). Check out these [essentials docs on auth](/docs/tutorial/auth#adding-auth-to-the-project) to see an example of usage. #### `onAuthSucceededRedirectTo: String` + The route to which Wasp will send a successfully authenticated after a successful login/signup. The default value is `"/"`. :::note Automatic redirect on successful login only works when using the Wasp-provided [Auth UI](/docs/auth/ui). ::: + +#### `signup: SignupOptions` + +Read more about the signup process customization API in the [Signup Fields Customization](#signup-fields-customization) section. + +### Signup Fields Customization + +If you want to add extra fields to the signup process, the server needs to know how to save them to the database. You do that by defining the `auth.signup.additionalFields` field in your `main.wasp` file. + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} +``` + +Then we'll export the `fields` object from the `server/auth/signup.js` file: + +```ts title="server/auth/signup.js" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + +```wasp title="main.wasp" {9-11} +app crudTesting { + // ... + auth: { + userEntity: User, + methods: { + usernameAndPassword: {}, + }, + onAuthFailedRedirectTo: "/login", + signup: { + additionalFields: import { fields } from "@server/auth/signup.js", + }, + }, +} +``` + +Then we'll export the `fields` object from the `server/auth/signup.ts` file: + +```ts title="server/auth/signup.ts" +import { defineAdditionalSignupFields } from '@wasp/auth/index.js' + +export const fields = defineAdditionalSignupFields({ + address: async (data) => { + const address = data.address + if (typeof address !== 'string') { + throw new Error('Address is required') + } + if (address.length < 5) { + throw new Error('Address must be at least 5 characters long') + } + return address + }, +}) +``` + + + + +The `fields` object is an object where the keys represent the field name, and the values are functions which receive the data sent from the client\* and return the value of the field. + +If the field value is invalid, the function should throw an error. + + + +\* We exclude the `password` field from this object to prevent it from being saved as plain-text in the database. The `password` field is handled by Wasp's auth backend. + + +### `SignupForm` Customization + +To customize the `SignupForm` component, you need to pass in the `additionalFields` prop. It can be either a list of extra fields or a render function. + + + + +```jsx title="client/SignupPage.jsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + +```tsx title="client/SignupPage.tsx" +import { SignupForm } from '@wasp/auth/forms/Signup' +import { + FormError, + FormInput, + FormItemGroup, + FormLabel, +} from '@wasp/auth/forms/internal/Form' + +export const SignupPage = () => { + return ( + { + return ( + + Phone Number + + {form.formState.errors.phoneNumber && ( + + {form.formState.errors.phoneNumber.message} + + )} + + ) + }, + ]} + /> + ) +} +``` + + + + +The extra fields can be either **objects** or **render functions** (you can combine them): + +1. Objects are a simple way to describe new fields you need, but a bit less flexible than render functions. + + The objects have the following properties: + + - `name` + - the name of the field + - `label` + + - the label of the field (used in the UI) + + - `type` + + - the type of the field, which can be `input` or `textarea` + + - `validations` + - an object with the validation rules for the field. The keys are the validation names, and the values are the validation error messages. Read more about the available validation rules in the [react-hook-form docs](https://react-hook-form.com/api/useform/register#register). + +2. Render functions receive the `react-hook-form` object and the form state as arguments, and they can use them to render arbitrary UI elements. + + The render function has the following signature: + + ```ts + ;(form: UseFormReturn, state: FormState) => React.ReactNode + ``` + + - `form` + + - the `react-hook-form` object, read more about it in the [react-hook-form docs](https://react-hook-form.com/api/useform) + - you need to use the `form.register` function to register your fields + + - `state` + + - the form state object which has the following properties: + - `isLoading: boolean` + - whether the form is currently submitting diff --git a/web/docs/auth/ui.md b/web/docs/auth/ui.md index b57ea4f5b..2070a9ed3 100644 --- a/web/docs/auth/ui.md +++ b/web/docs/auth/ui.md @@ -218,6 +218,8 @@ export function SignupPage() { 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 Used with authentication. diff --git a/web/prettier.config.js b/web/prettier.config.js new file mode 100644 index 000000000..bab4b2ea0 --- /dev/null +++ b/web/prettier.config.js @@ -0,0 +1,6 @@ +// Used to format the code in the docs +module.exports = { + trailingComma: 'es5', + semi: false, + singleQuote: true, +} \ No newline at end of file