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 (
+ <>
+
+
+
+
+
+ >
+ )
+}
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(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(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'),
+ }}
+ />
)}