diff --git a/packages/oauth-provider/src/assets/app/backend-data.ts b/packages/oauth-provider/src/assets/app/backend-data.ts index f98e7829f37..a163bb672f5 100644 --- a/packages/oauth-provider/src/assets/app/backend-data.ts +++ b/packages/oauth-provider/src/assets/app/backend-data.ts @@ -7,6 +7,12 @@ export type FieldDefinition = { title?: string } +export type ExtraFieldDefinition = FieldDefinition & { + type: 'text' | 'password' | 'date' | 'captcha' + required?: boolean + [_: string]: unknown +} + export type LinkDefinition = { title: string href: string @@ -24,6 +30,14 @@ export type CustomizationData = { remember?: FieldDefinition } } + signUp?: { + fields?: { + username?: FieldDefinition + password?: FieldDefinition + remember?: FieldDefinition + } + extraFields?: Record + } } export type ErrorData = { diff --git a/packages/oauth-provider/src/assets/app/components/help-card.tsx b/packages/oauth-provider/src/assets/app/components/help-card.tsx new file mode 100644 index 00000000000..23c0434fd06 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/help-card.tsx @@ -0,0 +1,42 @@ +import { HTMLAttributes } from 'react' +import { LinkDefinition } from '../backend-data' +import { clsx } from '../lib/clsx' + +export type HelpCardProps = { + links?: readonly LinkDefinition[] +} + +export function HelpCard({ + links, + + className, + ...attrs +}: HelpCardProps & + Omit< + HTMLAttributes, + keyof HelpCardProps | 'children' + >) { + const helpLink = links?.find((l) => l.rel === 'help') + + if (!helpLink) return null + + return ( +

+ Having trouble?{' '} + + Contact {helpLink.title} + +

+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/sign-up-account-form.tsx b/packages/oauth-provider/src/assets/app/components/sign-up-account-form.tsx new file mode 100644 index 00000000000..acd5cfac192 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/sign-up-account-form.tsx @@ -0,0 +1,208 @@ +import { + FormHTMLAttributes, + ReactNode, + SyntheticEvent, + useCallback, + useState, +} from 'react' + +import { clsx } from '../lib/clsx' +import { ErrorCard } from './error-card' + +export type SignUpAccountFormOutput = { + username: string + password: string +} + +export type SignUpAccountFormProps = { + onSubmit: (credentials: SignUpAccountFormOutput) => void | PromiseLike + submitLabel?: ReactNode + submitAria?: string + + onCancel?: () => void + cancelLabel?: ReactNode + cancelAria?: string + + username?: string + usernamePlaceholder?: string + usernameLabel?: string + usernameAria?: string + usernamePattern?: string + usernameTitle?: string + + passwordPlaceholder?: string + passwordLabel?: string + passwordAria?: string + passwordPattern?: string + passwordTitle?: string +} + +export function SignUpAccountForm({ + onSubmit, + submitAria = 'Next', + submitLabel = submitAria, + + onCancel = undefined, + cancelAria = 'Cancel', + cancelLabel = cancelAria, + + username: defaultUsername = '', + usernameLabel = 'Username', + usernameAria = usernameLabel, + usernamePlaceholder = usernameLabel, + usernamePattern, + usernameTitle, + + passwordLabel = 'Password', + passwordAria = passwordLabel, + passwordPlaceholder = passwordLabel, + passwordPattern, + passwordTitle, + + className, + children, + ...attrs +}: SignUpAccountFormProps & + Omit, keyof SignUpAccountFormProps>) { + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const doSubmit = useCallback( + async ( + event: SyntheticEvent< + HTMLFormElement & { + username: HTMLInputElement + password: HTMLInputElement + }, + SubmitEvent + >, + ) => { + event.preventDefault() + + const credentials = { + username: event.currentTarget.username.value, + password: event.currentTarget.password.value, + } + + setLoading(true) + setErrorMessage(null) + try { + await onSubmit(credentials) + } catch (err) { + setErrorMessage(parseErrorMessage(err)) + } finally { + setLoading(false) + } + }, + [onSubmit, setErrorMessage, setLoading], + ) + + return ( +
+
+ + +
+ @ + setErrorMessage(null)} + className="relative m-0 block w-[1px] min-w-0 flex-auto px-3 py-[0.25rem] leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100 disabled:text-gray-500" + placeholder={usernamePlaceholder} + aria-label={usernameAria} + autoCapitalize="none" + autoCorrect="off" + autoComplete="username" + spellCheck="false" + dir="auto" + enterKeyHint="next" + required + defaultValue={defaultUsername} + pattern={usernamePattern} + title={usernameTitle} + /> +
+ + + +
+ * + setErrorMessage(null)} + className="relative m-0 block w-[1px] min-w-0 flex-auto px-3 py-[0.25rem] leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100" + placeholder={passwordPlaceholder} + aria-label={passwordAria} + autoCapitalize="none" + autoCorrect="off" + autoComplete="new-password" + dir="auto" + enterKeyHint="done" + spellCheck="false" + required + pattern={passwordPattern} + title={passwordTitle} + /> +
+
+ + {children &&
{children}
} + + {errorMessage && } + +
+ +
+ + + {onCancel && ( + + )} + +
+
+ + ) +} + +function parseErrorMessage(err: unknown): string { + switch ((err as any)?.message) { + case 'Invalid credentials': + return 'Invalid username or password' + default: + console.error(err) + return 'An unknown error occurred' + } +} diff --git a/packages/oauth-provider/src/assets/app/components/sign-up-disclaimer.tsx b/packages/oauth-provider/src/assets/app/components/sign-up-disclaimer.tsx new file mode 100644 index 00000000000..0acc39f6fa8 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/sign-up-disclaimer.tsx @@ -0,0 +1,44 @@ +import { HTMLAttributes } from 'react' +import { LinkDefinition } from '../backend-data' +import { clsx } from '../lib/clsx' + +export type SignUpDisclaimerProps = { + links?: readonly LinkDefinition[] +} + +export function SignUpDisclaimer({ + links, + + className, + ...attrs +}: SignUpDisclaimerProps & + Omit< + HTMLAttributes, + keyof SignUpDisclaimerProps | 'children' + >) { + const relevantLinks = links?.filter( + (l) => l.rel === 'privacy-policy' || l.rel === 'terms-of-service', + ) + + return ( +

+ By creating an account you agree to the{' '} + {relevantLinks && relevantLinks.length + ? relevantLinks.map((l, i, a) => ( + + {i > 0 && (i < a.length - 1 ? ', ' : ' and ')} + + {l.title} + + + )) + : 'Terms of Service and Privacy Policy'} + . +

+ ) +} diff --git a/packages/oauth-provider/src/assets/app/hooks/use-api.ts b/packages/oauth-provider/src/assets/app/hooks/use-api.ts index b93dd0afa4a..a83b1fb54bc 100644 --- a/packages/oauth-provider/src/assets/app/hooks/use-api.ts +++ b/packages/oauth-provider/src/assets/app/hooks/use-api.ts @@ -12,6 +12,12 @@ export type SignInCredentials = { remember?: boolean } +export type SignUpData = { + username: string + password: string + extra?: Record +} + export function useApi( { clientId, @@ -68,6 +74,14 @@ export function useApi( [api, performRedirect, clientId, setSessions], ) + const doSignUp = useCallback( + (data: SignUpData) => { + // + console.error('SIGNUPPP', data, api) + }, + [api], + ) + const doAccept = useCallback( async (account: Account) => { performRedirect(await api.accept(account)) @@ -84,6 +98,7 @@ export function useApi( setSession, doSignIn, + doSignUp, doAccept, doReject, } diff --git a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx index eaa574ba49b..6534a656428 100644 --- a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx +++ b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx @@ -6,6 +6,7 @@ import { useApi } from '../hooks/use-api' import { useBoundDispatch } from '../hooks/use-bound-dispatch' import { AcceptView } from './accept-view' import { SignInView } from './sign-in-view' +import { SignUpView } from './sign-up-view' import { WelcomeView } from './welcome-view' export type AuthorizeViewProps = { @@ -19,19 +20,18 @@ export function AuthorizeView({ }: AuthorizeViewProps) { const forceSignIn = authorizeData?.loginHint != null - const [view, setView] = useState<'welcome' | 'sign-in' | 'accept' | 'done'>( - forceSignIn ? 'sign-in' : 'welcome', - ) + const [view, setView] = useState< + 'welcome' | 'sign-in' | 'sign-up' | 'accept' | 'done' + >(forceSignIn ? 'sign-in' : 'welcome') const showDone = useBoundDispatch(setView, 'done') const showSignIn = useBoundDispatch(setView, 'sign-in') + const showSignUp = useBoundDispatch(setView, 'sign-up') const showAccept = useBoundDispatch(setView, 'accept') const showWelcome = useBoundDispatch(setView, 'welcome') - const { sessions, setSession, doAccept, doReject, doSignIn } = useApi( - authorizeData, - { onRedirected: showDone }, - ) + const { sessions, setSession, doAccept, doReject, doSignIn, doSignUp } = + useApi(authorizeData, { onRedirected: showDone }) const session = sessions.find((s) => s.selected && !s.loginRequired) useEffect(() => { @@ -48,12 +48,24 @@ export function AuthorizeView({ logo={customizationData?.logo} links={customizationData?.links} onSignIn={showSignIn} - onSignUp={undefined} + onSignUp={showSignUp} onCancel={doReject} /> ) } + if (view === 'sign-up') { + return ( + + ) + } + if (view === 'sign-in') { return ( ReactNode + stepTitle?: (step: number, total: number) => ReactNode + + links?: LinkDefinition[] + fields?: { + username?: FieldDefinition + password?: FieldDefinition + } + extraFields?: Record + onSignUp: (data: { + username: string + password: string + extra?: Record + }) => void | PromiseLike + onBack?: () => void +} + +const defaultStepName: NonNullable = ( + step, + total, +) => `Step ${step} of ${total}` +const defaultStepTitle: NonNullable = ( + step, + total, +) => { + switch (step) { + case 1: + return 'Your account' + default: + return null + } +} + +export function SignUpView({ + stepName = defaultStepName, + stepTitle = defaultStepTitle, + + links, + fields, + extraFields, + + onSignUp, + onBack, +}: SignUpViewProps) { + const [_credentials, setCredentials] = + useState(null) + const [step, setStep] = useState<1 | 2>(1) + + const [extraFieldsEntries, setExtraFieldsEntries] = useState( + extraFields != null ? Object.entries(extraFields) : [], + ) + + const hasExtraFields = extraFieldsEntries.length > 0 + const stepCount = hasExtraFields ? 2 : 1 + + const doSubmitAccount = useCallback( + (credentials: SignUpAccountFormOutput) => { + setCredentials(credentials) + if (hasExtraFields) { + setStep(2) + } else { + onSignUp(credentials) + } + }, + [hasExtraFields, onSignUp, setCredentials, setStep], + ) + + useEffect(() => { + let ef = extraFieldsEntries + for (const entry of extraFields != null + ? Object.entries(extraFields) + : []) { + ef = upsert(ef || [], entry, (a) => a[0] === entry[0]) + } + if (ef !== extraFieldsEntries) setExtraFieldsEntries(ef) + }, [extraFields]) + + return ( + +
+

+ {stepName(step, stepCount)} +

+

+ {stepTitle(step, stepCount)} +

+ + {step === 1 && ( + + + + )} + + +
+
+ ) +} diff --git a/packages/oauth-provider/src/output/customization.ts b/packages/oauth-provider/src/output/customization.ts index 8265019779d..56c1ca806a4 100644 --- a/packages/oauth-provider/src/output/customization.ts +++ b/packages/oauth-provider/src/output/customization.ts @@ -11,6 +11,12 @@ export type FieldDefinition = { title?: string } +export type ExtraFieldDefinition = FieldDefinition & { + type: 'text' | 'password' | 'date' | 'captcha' + required?: boolean + [_: string]: unknown +} + export type Customization = { name?: string logo?: string @@ -28,6 +34,16 @@ export type Customization = { remember?: FieldDefinition } } + + signUp?: + | false + | { + fields?: { + username?: FieldDefinition + password?: FieldDefinition + } + extraFields?: Record + } } export function buildCustomizationData({ @@ -35,12 +51,14 @@ export function buildCustomizationData({ logo, links, signIn, + signUp = false, }: Customization = {}) { return { name, logo, links, signIn, + signUp: signUp || undefined, } } diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index a43947cdd30..1e60215c6a2 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -286,6 +286,46 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { password: {}, }, }, + signUp: { + fields: { + username: { + label: 'Email address', + placeholder: 'Enter your email address', + pattern: + '[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}', + title: 'Must be a valid email address', + }, + password: { + pattern: + '(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^0-9a-zA-Z]).{8,}', + title: + 'Must contain at least one uppercase, one lowercase, one number, and one special character, and be at least 8 characters long', + }, + }, + extraFields: { + handle: { + label: 'Enter your user handle', + placeholder: 'e.g. alice', + type: 'text' as const, + pattern: '[a-z0-9-]{3,20}', + }, + birthdate: { + label: 'Birth date', + type: 'date' as const, + required: true, + }, + inviteCode: { + label: 'Invite Code', + type: 'text' as const, + required: invitesCfg.required, + }, + captcha: { + label: 'Are you human?', + type: 'captcha' as const, + required: true, + }, + }, + }, }, }, }