From 4438e851757de929f8aa187602f6352b13130300 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 22 Sep 2026 22:27:13 +0200 Subject: [PATCH] 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 --- .../components/auth/CredentialsForm.test.tsx | 101 +++++++++++++ .../src/components/auth/CredentialsForm.tsx | 135 ++++++++++++++++++ frontend/src/pages/Auth.tsx | 98 ++++--------- frontend/src/pages/Join.tsx | 112 +++++---------- 4 files changed, 300 insertions(+), 146 deletions(-) create mode 100644 frontend/src/components/auth/CredentialsForm.test.tsx create mode 100644 frontend/src/components/auth/CredentialsForm.tsx diff --git a/frontend/src/components/auth/CredentialsForm.test.tsx b/frontend/src/components/auth/CredentialsForm.test.tsx new file mode 100644 index 0000000..15111d6 --- /dev/null +++ b/frontend/src/components/auth/CredentialsForm.test.tsx @@ -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( + , + ) + 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() + }) +}) diff --git a/frontend/src/components/auth/CredentialsForm.tsx b/frontend/src/components/auth/CredentialsForm.tsx new file mode 100644 index 0000000..8955024 --- /dev/null +++ b/frontend/src/components/auth/CredentialsForm.tsx @@ -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 ( + <> +
+ + +
+
+ {isRegister && ( + + setDisplayName(e.target.value)} + /> + + )} + + setEmail(e.target.value)} + /> + + + setPassword(e.target.value)} + /> + + {error && {error}} + +
+ + ) +} diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index 1ecdbbe..17ae3a5 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -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('login') const [error, setError] = useState(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() {

{isRegister ? t('auth.createAccountTitle') : t('auth.loginTitle')}

-
- - -
-
void handleSubmit(e)}> - {isRegister && ( - - setDisplayName(e.target.value)} - /> - - )} - - setEmail(e.target.value)} - /> - - - setPassword(e.target.value)} - /> - - {error && {error}} - -
+ 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'), + }} + />
) diff --git a/frontend/src/pages/Join.tsx b/frontend/src/pages/Join.tsx index a4f4498..08052cf 100644 --- a/frontend/src/pages/Join.tsx +++ b/frontend/src/pages/Join.tsx @@ -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('checking') const [errorKey, setErrorKey] = useState(null) - const [mode, setMode] = useState('register') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [displayName, setDisplayName] = useState('') + const [mode, setMode] = useState('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' && ( - <> -
- - -
-
void handleSubmit(e)}> - {mode === 'register' && ( - - setDisplayName(e.target.value)} - /> - - )} - - setEmail(e.target.value)} - /> - - - setPassword(e.target.value)} - /> - - {errorKey && {t(errorKey)}} - -
- + 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'), + }} + /> )}