From 68105e3094c16e00750b239af660f481b573b6f2 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 17:14:37 -0700 Subject: [PATCH] feat(auth): redesign the signup page as a split-screen brand moment /register was a bare centered form on ivory, shared with /login, with zero brand presence -- the product's actual front door (no marketing route exists) looked identical to every other unauthenticated screen. Adds: - A split-screen layout (form left, brand panel right, stacking below 768px) via a new `split` variant on the existing AuthLayout -- /login is untouched, still the plain centered column. - SignupBrandPanel: a deliberate one-time reversal of the app's normal ink-on-paper (uses --color-text/--color-surface directly, so it still reads as a reversal in dark mode rather than flattening into it), the serif "Trueup" wordmark on its first public-page appearance, real grounded copy (four model portfolios, monthly rebalancing) instead of generic fintech copy, and the genuine three-step sequence -- create account, verify identity, link bank -- numbered because it actually is one, with the current step marked via the system's sanctioned "current selection" use of the accent color. - A password visibility toggle, via a new optional `labelExtra` slot on the shared Input component (purely additive -- every other caller is unaffected, verified by screenshot). - A segmented password-strength meter driven by the exact same describePassword() the checklist already used -- no new fabricated scale -- as the page's one deliberate interactive/animated moment, respecting the app's existing global reduced-motion rule. - A real fix for the duplicate-email case: the backend now raises ValidationError(..., code="email_taken") instead of the shared validation_failed code, so the form can show "This account already exists -- log in instead." with a working link, instead of the generic "check your details" message the dead-code fallback string was silently never reaching. Deliberately not added: a Terms-of-Service checkbox (no such page/legal content exists yet in the app -- would mean inventing legal copy) and any specific regulatory disclosure claims (kept the one trust line to something true by construction). Verified: backend 24/24 auth tests pass (including the updated duplicate-email assertion); npm run lint and build clean; full manual pass in the browser -- real registration through the running local backend, the duplicate-email path end to end with the new message and link, password toggle and strength meter both interactive, light and dark themes (dark mode's inversion checked explicitly, panel correctly flips light-on-dark), mobile width (375px, panel correctly hidden, single column), and a regression screenshot confirming /login (which doesn't use the new labelExtra prop) is pixel-identical to before. Co-Authored-By: Claude Opus 5 --- backend/app/controllers/api/auth.py | 4 +- backend/tests/api/test_auth.py | 4 +- frontend/src/components/Icon/paths.js | 9 ++ frontend/src/components/Input/Input.css | 7 ++ frontend/src/components/Input/Input.jsx | 17 ++- .../src/features/auth/components/AuthForm.css | 46 ++++++++ .../features/auth/components/RegisterForm.jsx | 57 +++++++++- .../auth/components/SignupBrandPanel.css | 107 ++++++++++++++++++ .../auth/components/SignupBrandPanel.jsx | 52 +++++++++ frontend/src/pages/AuthLayout.css | 35 ++++++ frontend/src/pages/AuthLayout.jsx | 15 ++- frontend/src/pages/RegisterPage.jsx | 3 +- frontend/src/utils/apiErrorMessage.js | 1 + 13 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 frontend/src/features/auth/components/SignupBrandPanel.css create mode 100644 frontend/src/features/auth/components/SignupBrandPanel.jsx 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.',