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/components/balance/BalanceFlow.tsx b/frontend/src/components/balance/BalanceFlow.tsx
index d587db2..b678621 100644
--- a/frontend/src/components/balance/BalanceFlow.tsx
+++ b/frontend/src/components/balance/BalanceFlow.tsx
@@ -14,6 +14,7 @@ import type { PersonalData } from '@/types'
import { QRCodeModal } from '@/components/modals/QRCodeModal'
import { ParentBalanceForm, type ParentBalanceInitial } from '@/components/modals/ParentBalanceForm'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
+import { Alert } from '@/components/common/Alert'
import './balance-flow.css'
export interface BalanceFlowProps {
@@ -325,11 +326,7 @@ export function BalanceFlow({
disabled={isAddingDeducedPartner}
/>
- {deducedPartnerError && (
-
- {deducedPartnerError}
-
- )}
+ {deducedPartnerError && {deducedPartnerError}}
- {(error || localError) && (
-
- {error || localError}
-
- )}
+ {(error || localError) &&
{error || localError}}
{!wish && (
@@ -388,12 +386,8 @@ export function PeriodPopover({
)}
-
-
+
setForm((prev) => ({ ...prev, name: e.target.value }))}
@@ -403,14 +397,10 @@ export function PeriodPopover({
data-testid="period-name-input"
aria-label={t('calendar.periodNameAria')}
/>
-
+
-
-
+
setForm((prev) => ({ ...prev, startDate: e.target.value }))}
@@ -418,14 +408,10 @@ export function PeriodPopover({
disabled={isSaving}
data-testid="period-start-input"
/>
-
+
-
-
+
setForm((prev) => ({ ...prev, endDate: e.target.value }))}
@@ -433,14 +419,10 @@ export function PeriodPopover({
disabled={isSaving}
data-testid="period-end-input"
/>
-
+
-
-
+
-
+