Share the login/register form between Auth and Join

CredentialsForm owns the mode tabs, the name/email/password fields, the
error and the submit button; the two pages keep only their own wiring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:27:13 +02:00
parent d5596afe10
commit 4438e85175
4 changed files with 300 additions and 146 deletions
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CredentialsForm, type CredentialsFormMode } from './CredentialsForm'
const labels = {
loginTab: 'Log in',
registerTab: 'Create account',
nameLabel: 'Name',
namePlaceholder: 'Your name',
emailLabel: 'Email',
emailPlaceholder: 'Email address',
passwordLabel: 'Password',
passwordPlaceholder: 'Password',
submitLabel: 'Submit',
}
function renderForm(mode: CredentialsFormMode, overrides: { error?: string; disabled?: boolean } = {}) {
const onModeChange = vi.fn()
const onSubmit = vi.fn()
render(
<CredentialsForm
mode={mode}
onModeChange={onModeChange}
onSubmit={onSubmit}
error={overrides.error}
disabled={overrides.disabled}
idPrefix="cf"
classPrefix="cf-page"
labels={labels}
/>,
)
return { onModeChange, onSubmit }
}
describe('CredentialsForm', () => {
it('renders the login mode without a name field', () => {
renderForm('login')
expect(screen.getByTestId('cf-tab-login')).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByTestId('cf-tab-register')).toHaveAttribute('aria-pressed', 'false')
expect(screen.queryByPlaceholderText('Your name')).not.toBeInTheDocument()
expect(screen.getByTestId('cf-email')).toBeInTheDocument()
expect(screen.getByTestId('cf-password')).toBeInTheDocument()
})
it('renders the register mode with a name field', () => {
renderForm('register')
expect(screen.getByTestId('cf-tab-register')).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByTestId('cf-tab-login')).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByPlaceholderText('Your name')).toBeInTheDocument()
})
it('calls onModeChange when a tab is clicked', () => {
const { onModeChange } = renderForm('login')
fireEvent.click(screen.getByTestId('cf-tab-register'))
expect(onModeChange).toHaveBeenCalledWith('register')
fireEvent.click(screen.getByTestId('cf-tab-login'))
expect(onModeChange).toHaveBeenCalledWith('login')
})
it('shows the error alert when an error is given', () => {
renderForm('login', { error: 'Something went wrong' })
expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong')
})
it('renders no alert when there is no error', () => {
renderForm('login')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('submits the entered name, email and password for register mode', () => {
const { onSubmit } = renderForm('register')
fireEvent.change(screen.getByPlaceholderText('Your name'), { target: { value: 'Alex' } })
fireEvent.change(screen.getByTestId('cf-email'), { target: { value: 'a@b.com' } })
fireEvent.change(screen.getByTestId('cf-password'), { target: { value: 'secret' } })
fireEvent.click(screen.getByTestId('cf-submit'))
expect(onSubmit).toHaveBeenCalledWith({ displayName: 'Alex', email: 'a@b.com', password: 'secret' })
})
it('submits an empty displayName for login mode, where the field is hidden', () => {
const { onSubmit } = renderForm('login')
fireEvent.change(screen.getByTestId('cf-email'), { target: { value: 'a@b.com' } })
fireEvent.change(screen.getByTestId('cf-password'), { target: { value: 'secret' } })
fireEvent.click(screen.getByTestId('cf-submit'))
expect(onSubmit).toHaveBeenCalledWith({ displayName: '', email: 'a@b.com', password: 'secret' })
})
it('disables the submit button when disabled is true', () => {
renderForm('login', { disabled: true })
expect(screen.getByTestId('cf-submit')).toBeDisabled()
})
it('does not disable the submit button when disabled is not given', () => {
renderForm('login')
expect(screen.getByTestId('cf-submit')).not.toBeDisabled()
})
})
@@ -0,0 +1,135 @@
import { useState, type ReactNode } from 'react'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
export type CredentialsFormMode = 'login' | 'register'
export interface CredentialsFormValues {
displayName: string
email: string
password: string
}
/** All copy the form needs, so the two callers (`Auth`, `Join`) can supply
* their own translated strings without this component knowing their i18n
* keys. */
export interface CredentialsFormLabels {
loginTab: ReactNode
registerTab: ReactNode
nameLabel: ReactNode
namePlaceholder: string
emailLabel: ReactNode
emailPlaceholder: string
passwordLabel: ReactNode
passwordPlaceholder: string
submitLabel: ReactNode
}
export interface CredentialsFormProps {
mode: CredentialsFormMode
onModeChange: (mode: CredentialsFormMode) => void
onSubmit: (values: CredentialsFormValues) => void
error?: ReactNode
disabled?: boolean | undefined
/** Prefixes every id/data-testid, e.g. `auth` → `auth-email`. */
idPrefix: string
/** Prefixes the BEM classes for the tabs/form, e.g. `auth-page` →
* `auth-page__tabs`. */
classPrefix: string
labels: CredentialsFormLabels
}
/**
* The login/register tab pair + credentials form shared by `Auth` (the
* app's front door) and `Join` (the partner-invite landing page) — until
* now hand-duplicated between them. Owns the field values internally;
* callers only see the submitted `{ displayName, email, password }` tuple
* and the mode toggle, and stay responsible for the error banner's content,
* the submit button's disabled state and label, and what happens on submit.
*/
export function CredentialsForm({
mode,
onModeChange,
onSubmit,
error,
disabled,
idPrefix,
classPrefix,
labels,
}: CredentialsFormProps): ReactNode {
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const isRegister = mode === 'register'
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({ displayName, email, password })
}
return (
<>
<div className={`segmented ${classPrefix}__tabs`} role="group">
<button
type="button"
className={`segmented__option ${classPrefix}__tab`}
aria-pressed={!isRegister}
onClick={() => onModeChange('login')}
data-testid={`${idPrefix}-tab-login`}
>
{labels.loginTab}
</button>
<button
type="button"
className={`segmented__option ${classPrefix}__tab`}
aria-pressed={isRegister}
onClick={() => onModeChange('register')}
data-testid={`${idPrefix}-tab-register`}
>
{labels.registerTab}
</button>
</div>
<form className={`${classPrefix}__form`} onSubmit={handleSubmit}>
{isRegister && (
<FormField id={`${idPrefix}-name`} label={labels.nameLabel}>
<input
className="field__control"
placeholder={labels.namePlaceholder}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</FormField>
)}
<FormField id={`${idPrefix}-email`} label={labels.emailLabel}>
<input
type="email"
data-testid={`${idPrefix}-email`}
className="field__control"
placeholder={labels.emailPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</FormField>
<FormField id={`${idPrefix}-password`} label={labels.passwordLabel}>
<input
type="password"
data-testid={`${idPrefix}-password`}
className="field__control"
placeholder={labels.passwordPlaceholder}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
{error && <Alert variant="error">{error}</Alert>}
<button
type="submit"
className="btn btn--primary btn--block"
disabled={disabled}
data-testid={`${idPrefix}-submit`}
>
{labels.submitLabel}
</button>
</form>
</>
)
}
+30 -68
View File
@@ -4,8 +4,11 @@ import { useAuth } from '@/context/AuthContext'
import { useWorkspace } from '@/context/WorkspaceContext'
import { useTranslation } from '@/i18n'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import {
CredentialsForm,
type CredentialsFormMode,
type CredentialsFormValues,
} from '@/components/auth/CredentialsForm'
import './auth.css'
export function Auth() {
@@ -13,11 +16,9 @@ export function Auth() {
const { isLoading: wsLoading } = useWorkspace()
const { t } = useTranslation()
const navigate = useNavigate()
const [isRegister, setIsRegister] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [mode, setMode] = useState<CredentialsFormMode>('login')
const [error, setError] = useState<string | null>(null)
const isRegister = mode === 'register'
useEffect(() => {
if (isAuthenticated && !wsLoading) {
@@ -25,17 +26,15 @@ export function Auth() {
}
}, [isAuthenticated, wsLoading, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const handleSubmit = async ({ displayName, email, password }: CredentialsFormValues) => {
setError(null)
try {
if (isRegister) {
await register({ email, password, displayName })
navigate('/', { replace: true })
} else {
await login({ email, password })
navigate('/', { replace: true })
}
navigate('/', { replace: true })
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('auth.genericError'))
}
@@ -44,8 +43,8 @@ export function Auth() {
// Switching tabs clears any error from the previous tab's attempt — a
// failed login's error text should not linger, confusingly, once the
// person has moved on to creating an account (or back).
const switchTab = (register: boolean) => {
setIsRegister(register)
const switchMode = (next: CredentialsFormMode) => {
setMode(next)
setError(null)
}
@@ -57,62 +56,25 @@ export function Auth() {
<div className="auth-page__column">
<img className="auth-page__mark" src="/img/icon-64.png" alt="" width={32} height={32} />
<h1 className="auth-page__title">{isRegister ? t('auth.createAccountTitle') : t('auth.loginTitle')}</h1>
<div className="segmented auth-page__tabs" role="group">
<button
type="button"
className="segmented__option auth-page__tab"
aria-pressed={!isRegister}
onClick={() => switchTab(false)}
data-testid="auth-tab-login"
>
{t('auth.loginButton')}
</button>
<button
type="button"
className="segmented__option auth-page__tab"
aria-pressed={isRegister}
onClick={() => switchTab(true)}
data-testid="auth-tab-register"
>
{t('auth.createAccountButton')}
</button>
</div>
<form className="auth-page__form" onSubmit={(e) => void handleSubmit(e)}>
{isRegister && (
<FormField id="auth-name" label={t('auth.nameLabel')}>
<input
className="field__control"
placeholder={t('auth.namePlaceholder')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</FormField>
)}
<FormField id="auth-email" label={t('auth.emailLabel')}>
<input
type="email"
data-testid="auth-email"
className="field__control"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</FormField>
<FormField id="auth-password" label={t('auth.passwordLabel')}>
<input
type="password"
data-testid="auth-password"
className="field__control"
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
{error && <Alert variant="error">{error}</Alert>}
<button type="submit" className="btn btn--primary btn--block" data-testid="auth-submit">
{isRegister ? t('auth.registerButton') : t('auth.loginButton')}
</button>
</form>
<CredentialsForm
mode={mode}
onModeChange={switchMode}
onSubmit={(values) => void handleSubmit(values)}
error={error}
idPrefix="auth"
classPrefix="auth-page"
labels={{
loginTab: t('auth.loginButton'),
registerTab: t('auth.createAccountButton'),
nameLabel: t('auth.nameLabel'),
namePlaceholder: t('auth.namePlaceholder'),
emailLabel: t('auth.emailLabel'),
emailPlaceholder: t('auth.emailPlaceholder'),
passwordLabel: t('auth.passwordLabel'),
passwordPlaceholder: t('auth.passwordPlaceholder'),
submitLabel: isRegister ? t('auth.registerButton') : t('auth.loginButton'),
}}
/>
</div>
</main>
)
+34 -78
View File
@@ -5,12 +5,15 @@ import { useWorkspace } from '@/context/WorkspaceContext'
import { redeemInvitation } from '@/services/api'
import { useTranslation, type TranslationKey } from '@/i18n'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import {
CredentialsForm,
type CredentialsFormMode,
type CredentialsFormValues,
} from '@/components/auth/CredentialsForm'
import './join.css'
type Phase = 'checking' | 'form' | 'redeeming' | 'error' | 'done'
type Mode = 'login' | 'register'
/** Maps a redeem/register failure's backend message to a translated,
* distinct reason. The backend (see `InvitationError` call sites in
@@ -43,10 +46,7 @@ export function Join() {
const [phase, setPhase] = useState<Phase>('checking')
const [errorKey, setErrorKey] = useState<TranslationKey | null>(null)
const [mode, setMode] = useState<Mode>('register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [mode, setMode] = useState<CredentialsFormMode>('register')
const [isSubmitting, setIsSubmitting] = useState(false)
// Guards against redeeming twice: once from a submit handler and again
@@ -84,8 +84,7 @@ export function Join() {
}
}, [authLoading, isAuthenticated, token, redeemAndGo])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const handleSubmit = async ({ displayName, email, password }: CredentialsFormValues) => {
if (!token) return
setErrorKey(null)
setIsSubmitting(true)
@@ -113,7 +112,7 @@ export function Join() {
}
}
const switchMode = (next: Mode) => {
const switchMode = (next: CredentialsFormMode) => {
setMode(next)
setErrorKey(null)
}
@@ -152,75 +151,32 @@ export function Join() {
)}
{phase === 'form' && (
<>
<div className="segmented join-page__tabs" role="group">
<button
type="button"
className="segmented__option join-page__tab"
aria-pressed={mode === 'login'}
onClick={() => switchMode('login')}
data-testid="join-tab-login"
>
{t('invite.joinLoginTab')}
</button>
<button
type="button"
className="segmented__option join-page__tab"
aria-pressed={mode === 'register'}
onClick={() => switchMode('register')}
data-testid="join-tab-register"
>
{t('invite.joinRegisterTab')}
</button>
</div>
<form className="join-page__form" onSubmit={(e) => void handleSubmit(e)}>
{mode === 'register' && (
<FormField id="join-name" label={t('invite.joinNameLabel')}>
<input
className="field__control"
placeholder={t('invite.joinNamePlaceholder')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</FormField>
)}
<FormField id="join-email" label={t('invite.joinEmailLabel')}>
<input
type="email"
data-testid="join-email"
className="field__control"
placeholder={t('invite.joinEmailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</FormField>
<FormField id="join-password" label={t('invite.joinPasswordLabel')}>
<input
type="password"
data-testid="join-password"
className="field__control"
placeholder={t('invite.joinPasswordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
{errorKey && <Alert variant="error">{t(errorKey)}</Alert>}
<button
type="submit"
className="btn btn--primary btn--block"
disabled={isSubmitting}
data-testid="join-submit"
>
{isSubmitting
? mode === 'register'
? t('invite.joinSubmittingRegister')
: t('invite.joinSubmittingLogin')
: mode === 'register'
? t('invite.joinSubmitRegister')
: t('invite.joinSubmitLogin')}
</button>
</form>
</>
<CredentialsForm
mode={mode}
onModeChange={switchMode}
onSubmit={(values) => void handleSubmit(values)}
error={errorKey ? t(errorKey) : null}
disabled={isSubmitting}
idPrefix="join"
classPrefix="join-page"
labels={{
loginTab: t('invite.joinLoginTab'),
registerTab: t('invite.joinRegisterTab'),
nameLabel: t('invite.joinNameLabel'),
namePlaceholder: t('invite.joinNamePlaceholder'),
emailLabel: t('invite.joinEmailLabel'),
emailPlaceholder: t('invite.joinEmailPlaceholder'),
passwordLabel: t('invite.joinPasswordLabel'),
passwordPlaceholder: t('invite.joinPasswordPlaceholder'),
submitLabel: isSubmitting
? mode === 'register'
? t('invite.joinSubmittingRegister')
: t('invite.joinSubmittingLogin')
: mode === 'register'
? t('invite.joinSubmitRegister')
: t('invite.joinSubmitLogin'),
}}
/>
)}
</div>
</main>