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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+29
-67
@@ -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)}
|
||||
<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'),
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
+28
-72
@@ -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"
|
||||
<CredentialsForm
|
||||
mode={mode}
|
||||
onModeChange={switchMode}
|
||||
onSubmit={(values) => void handleSubmit(values)}
|
||||
error={errorKey ? t(errorKey) : null}
|
||||
disabled={isSubmitting}
|
||||
data-testid="join-submit"
|
||||
>
|
||||
{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')}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
: t('invite.joinSubmitLogin'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user