diff --git a/backend/app/controllers/api/auth.py b/backend/app/controllers/api/auth.py index 962d9d8..581b9d9 100644 --- a/backend/app/controllers/api/auth.py +++ b/backend/app/controllers/api/auth.py @@ -116,7 +116,9 @@ def register() -> Any: with IdentityUnitOfWork(customer_id=None, role=SessionRole.ADMIN) as uow: existing = uow.customers.get_by_email(data.email) if existing is not None: - raise ValidationError("Email already registered") + # Distinct code (not the shared `validation_failed`) so the client can offer a real + # next step -- "log in instead" -- rather than the generic "check your details". + raise ValidationError("Email already registered", code="email_taken") customer = Customer(email=data.email, password_hash=hash_password(data.password)) uow.customers.add(customer) diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index 0face53..46df9a2 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -87,7 +87,9 @@ def test_register_rejects_a_duplicate_email(api_client: FlaskClient) -> None: second = _register(api_client) assert second.status_code == 422 - assert second.get_json()["code"] == "validation_failed" + # Distinct code -- not the shared `validation_failed` -- so the client can offer "log in + # instead" rather than a generic "check your details" message. + assert second.get_json()["code"] == "email_taken" @pytest.mark.parametrize( diff --git a/frontend/src/components/Icon/paths.js b/frontend/src/components/Icon/paths.js index 50327f9..7721dc3 100644 --- a/frontend/src/components/Icon/paths.js +++ b/frontend/src/components/Icon/paths.js @@ -27,4 +27,13 @@ export const ICON_PATHS = { 'M3.8 13v-2.8h2.8', ], menu: ['M2.5 4.5h11', 'M2.5 8h11', 'M2.5 11.5h11'], + eye: [ + 'M2 8c1.6-3.2 4-4.8 6-4.8s4.4 1.6 6 4.8c-1.6 3.2-4 4.8-6 4.8s-4.4-1.6-6-4.8z', + 'M8 8m-1.8 0a1.8 1.8 0 1 0 3.6 0a1.8 1.8 0 1 0 -3.6 0', + ], + 'eye-off': [ + 'M2 8c1.6-3.2 4-4.8 6-4.8s4.4 1.6 6 4.8c-1.6 3.2-4 4.8-6 4.8s-4.4-1.6-6-4.8z', + 'M8 8m-1.8 0a1.8 1.8 0 1 0 3.6 0a1.8 1.8 0 1 0 -3.6 0', + 'M3 3 13 13', + ], } diff --git a/frontend/src/components/Input/Input.css b/frontend/src/components/Input/Input.css index b305f3a..d5a3a56 100644 --- a/frontend/src/components/Input/Input.css +++ b/frontend/src/components/Input/Input.css @@ -4,6 +4,13 @@ gap: var(--space-1); } +.tu-field__label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + .tu-field__label { font-size: var(--font-size-body); font-weight: var(--font-weight-body-medium); diff --git a/frontend/src/components/Input/Input.jsx b/frontend/src/components/Input/Input.jsx index 63bb06f..c9fd85b 100644 --- a/frontend/src/components/Input/Input.jsx +++ b/frontend/src/components/Input/Input.jsx @@ -1,17 +1,24 @@ import { useId } from 'react' import './Input.css' -/** Design system §7.2. Label always visible above the field — never placeholder-as-label. */ -export function Input({ label, id, error, hint, className = '', ...rest }) { +/** + * Design system §7.2. Label always visible above the field — never placeholder-as-label. + * `labelExtra` is an optional node rendered at the label row's trailing edge (e.g. a + * show/hide-password toggle) — additive and unused by every existing caller. + */ +export function Input({ label, id, error, hint, labelExtra, className = '', ...rest }) { const generatedId = useId() const fieldId = id || generatedId const messageId = error || hint ? `${fieldId}-message` : undefined return (
- +
+ + {labelExtra} +
0 ? 'building' : 'empty' // Input's own aria-describedby (wired to its inline error message, id `${id}-message`) would be // clobbered by ours below since {...rest} is spread after it — so reconstruct both ids here // rather than losing the error announcement while the strength checklist is attached. @@ -100,8 +109,8 @@ export function RegisterForm() { } } - const serverErrorMessage = - status === 'error' ? getErrorMessage(error, 'Registration failed — this email may already be registered.') : null + const serverErrorMessage = status === 'error' ? getErrorMessage(error) : null + const isEmailTaken = status === 'error' && error?.code === 'email_taken' return (
@@ -121,7 +130,7 @@ export function RegisterForm() {
setShowPassword((value) => !value)} + controls={PASSWORD_ID} + /> + } required /> +
  • @@ -147,7 +168,7 @@ export function RegisterForm() {
setShowConfirmPassword((value) => !value)} + controls={CONFIRM_PASSWORD_ID} + /> + } required /> {serverErrorMessage && (

{serverErrorMessage} + {isEmailTaken && ( + <> + {' '} + Log in instead. + + )}

)} + ) +} + function RuleIcon({ met }) { return (
+
+

Trueup

+

+ Invest in one of four model portfolios. We rebalance automatically, every month, so you + don't have to. +

+
    + {STEPS.map((step, index) => ( +
  1. + +
    +

    {step.title}

    +

    {step.description}

    +
    +
  2. + ))} +
+

Your data is encrypted in transit and at rest.

+
+
+ ) +} diff --git a/frontend/src/pages/AuthLayout.css b/frontend/src/pages/AuthLayout.css index 52e38a0..afe7f27 100644 --- a/frontend/src/pages/AuthLayout.css +++ b/frontend/src/pages/AuthLayout.css @@ -6,3 +6,38 @@ padding: var(--space-6); background: var(--color-paper); } + +/* Full properties re-declared (not relying on cascade to unset the base rule's flex/padding) so + this reads correctly regardless of source order. */ +.tu-auth-layout--split { + display: grid; + grid-template-columns: 1fr; + min-height: 100vh; + padding: 0; + align-items: stretch; + justify-content: stretch; +} + +.tu-auth-layout--split .tu-auth-layout__form { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-6); + background: var(--color-paper); + min-height: 100vh; +} + +.tu-auth-layout--split .tu-auth-layout__panel { + display: none; +} + +@media (min-width: 768px) { + .tu-auth-layout--split { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .tu-auth-layout--split .tu-auth-layout__panel { + display: block; + min-height: 100vh; + } +} diff --git a/frontend/src/pages/AuthLayout.jsx b/frontend/src/pages/AuthLayout.jsx index 61f0be4..2d1e161 100644 --- a/frontend/src/pages/AuthLayout.jsx +++ b/frontend/src/pages/AuthLayout.jsx @@ -1,5 +1,18 @@ import './AuthLayout.css' -export function AuthLayout({ children }) { +/** + * `variant="split"` + `panel` renders a two-column layout (form left, brand panel right, + * stacking to one column below 768px) for the one public page that carries brand/marketing + * content (signup). Default stays the plain centered column every other auth screen uses. + */ +export function AuthLayout({ children, variant = 'centered', panel = null }) { + if (variant === 'split') { + return ( +
+
{children}
+
{panel}
+
+ ) + } return
{children}
} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 3839507..a08e6ab 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,9 +1,10 @@ import { RegisterForm } from '../features/auth/components/RegisterForm.jsx' +import { SignupBrandPanel } from '../features/auth/components/SignupBrandPanel.jsx' import { AuthLayout } from './AuthLayout.jsx' export function RegisterPage() { return ( - + }> ) diff --git a/frontend/src/utils/apiErrorMessage.js b/frontend/src/utils/apiErrorMessage.js index 7923ab4..b2ca79d 100644 --- a/frontend/src/utils/apiErrorMessage.js +++ b/frontend/src/utils/apiErrorMessage.js @@ -2,6 +2,7 @@ const MESSAGES_BY_CODE = { unauthenticated: 'Incorrect email or password.', validation_failed: 'Check your details and try again.', + email_taken: 'An account with this email already exists.', forbidden: "You don't have access to do that.", not_found: 'Not found.', conflict: 'That request has already been submitted.',