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/features/onboarding/components/BankLinkStep.jsx b/frontend/src/features/onboarding/components/BankLinkStep.jsx index d30985c..8175869 100644 --- a/frontend/src/features/onboarding/components/BankLinkStep.jsx +++ b/frontend/src/features/onboarding/components/BankLinkStep.jsx @@ -4,7 +4,12 @@ import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { useBankLink } from '../hooks/useBankLink.js' import './OnboardingStep.css' -export function BankLinkStep({ customerId, onCompleted }) { +/** + * `showTitle` defaults to true so FundingPage's existing usage (which relies on this component + * for its own heading, inside a bare Card with no title of its own) is unchanged. OnboardingPage + * passes `showTitle={false}` since its StepRow supplies the title instead. + */ +export function BankLinkStep({ customerId, onCompleted, showTitle = true }) { const { tokenStatus, status, error, ready, open, retryLinkToken } = useBankLink(customerId, { onLinked: onCompleted, }) @@ -15,20 +20,20 @@ export function BankLinkStep({ customerId, onCompleted }) { if (tokenStatus === 'error') { return ( -
+ <>

Couldn't start bank linking.

-
+ ) } return ( -
-

Link your bank

+ <> + {showTitle &&

Link your bank

}

Connect a bank account to fund your investments.

{status === 'error' && (

@@ -42,6 +47,6 @@ export function BankLinkStep({ customerId, onCompleted }) { Link bank account )} -

+ ) } diff --git a/frontend/src/features/onboarding/components/KycStep.jsx b/frontend/src/features/onboarding/components/KycStep.jsx index 8bd5dda..465441a 100644 --- a/frontend/src/features/onboarding/components/KycStep.jsx +++ b/frontend/src/features/onboarding/components/KycStep.jsx @@ -12,13 +12,25 @@ function resolveErrorMessage(error) { return error.message || 'Verification failed. Please try again.' } -export function KycStep({ customerId, onCompleted }) { +/** + * `kycStatus` is the customer's *stored* status (from useIdentityStatus) -- distinct from + * `status`, this hook's own local in-progress-attempt state. A `rejected` stored status must + * never look identical to "hasn't tried yet": a customer who was turned away deserves to be told + * that, not invited to click the same first-time prompt with no memory of what happened. + */ +export function KycStep({ customerId, kycStatus, onCompleted }) { const { status, error, startVerification } = useKycSession() const isBusy = BUSY_STATUSES.has(status) + const wasRejected = kycStatus === 'rejected' && status === 'idle' return ( -
-

Verify your identity

+ <> + {wasRejected && ( +

+ Your identity verification wasn't approved. Try again with a clear photo of a + valid, unexpired government-issued ID. +

+ )}

We use Stripe Identity to confirm who you are — you'll need a government-issued photo ID.

@@ -41,9 +53,9 @@ export function KycStep({ customerId, onCompleted }) { loading={isBusy} disabled={isBusy} > - Start identity verification + {wasRejected ? 'Try again' : 'Start identity verification'} )} -
+ ) } diff --git a/frontend/src/features/onboarding/components/OnboardingStep.css b/frontend/src/features/onboarding/components/OnboardingStep.css index 563736d..4bd2bc2 100644 --- a/frontend/src/features/onboarding/components/OnboardingStep.css +++ b/frontend/src/features/onboarding/components/OnboardingStep.css @@ -1,10 +1,3 @@ -.tu-onboarding-step { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--space-3); -} - .tu-onboarding-step__title { font-size: var(--font-size-heading-sm); line-height: var(--line-height-heading-sm); diff --git a/frontend/src/features/onboarding/components/OnboardingSteps.css b/frontend/src/features/onboarding/components/OnboardingSteps.css new file mode 100644 index 0000000..83e2e76 --- /dev/null +++ b/frontend/src/features/onboarding/components/OnboardingSteps.css @@ -0,0 +1,87 @@ +.tu-onboarding-steps { + display: flex; + flex-direction: column; +} + +/* Padding-bottom (not a parent `gap`) carries the vertical rhythm between rows, so the connector + line below can span exactly that distance and read as one continuous rail rather than two + disconnected segments. */ +.tu-step-row { + display: grid; + grid-template-columns: 24px 1fr; + column-gap: var(--space-3); + padding-bottom: var(--space-6); +} + +.tu-step-row--last { + padding-bottom: 0; +} + +.tu-step-row__rail { + position: relative; + display: flex; + justify-content: center; +} + +.tu-step-row__rail::after { + content: ''; + position: absolute; + top: 24px; + bottom: 0; + width: 1px; + background: var(--color-hairline); +} + +.tu-step-row--last .tu-step-row__rail::after { + display: none; +} + +.tu-step-row__body { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-top: 1px; +} + +.tu-step-row__title { + font-size: var(--font-size-heading-sm); + line-height: var(--line-height-heading-sm); + font-weight: var(--font-weight-heading-sm); + color: var(--color-text); + margin: 0; +} + +.tu-step-row--locked .tu-step-row__title { + color: var(--color-text-muted); +} + +.tu-step-marker { + flex: none; + width: 24px; + height: 24px; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-caption); + font-weight: var(--font-weight-data-strong); +} + +.tu-step-marker--current { + /* Accent fill for "current step" -- design-system.md §2.4's sanctioned second use beyond + interactive elements. Ink numeral on accent, not white: matches the pairing already proven + on SignupBrandPanel's own current-step marker, with better measured contrast. */ + border: 1px solid var(--color-accent); + background: var(--color-accent); + color: var(--color-text); +} + +.tu-step-marker--locked { + border: 1px solid var(--color-hairline); + background: var(--color-surface); + color: var(--color-text-muted); +} + +.tu-step-marker--completed { + color: var(--color-success); +} diff --git a/frontend/src/features/onboarding/components/OnboardingSteps.jsx b/frontend/src/features/onboarding/components/OnboardingSteps.jsx new file mode 100644 index 0000000..2c7de4e --- /dev/null +++ b/frontend/src/features/onboarding/components/OnboardingSteps.jsx @@ -0,0 +1,50 @@ +import { Card } from '../../../components/Card' +import { Icon } from '../../../components/Icon' +import './OnboardingSteps.css' + +/** + * The onboarding journey's shell: one Card holding an ordered set of `StepRow`s connected by a + * rail, so the whole sequence is visible at once instead of hiding every step but the current + * one. Reuses the same numbered-circle idiom already established on the signup page's brand + * panel (SignupBrandPanel.jsx) -- this is that same real sequence, continued, not a new device. + */ +export function OnboardingSteps({ children }) { + return {children} +} + +/** + * One step in the sequence. `status` drives both the marker and the row's own emphasis: + * - `current` -- the step the customer can act on right now (accent-filled numbered circle). + * - `locked` -- not reachable yet (muted outline, no interactive content underneath). + * - `completed` -- done (a checkmark, never a bare colored dot -- design-system §2.4's own + * never-color-alone rule). + * `last` suppresses the connector rail below the marker for the final step. + */ +export function StepRow({ number, status, title, last = false, children }) { + return ( +
+
+ +
+
+

{title}

+ {children} +
+
+ ) +} + +function StepMarker({ number, status }) { + if (status === 'completed') { + return ( + + + + ) + } + return ( + + ) +} 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/OnboardingPage.css b/frontend/src/pages/OnboardingPage.css index a47d865..83e548d 100644 --- a/frontend/src/pages/OnboardingPage.css +++ b/frontend/src/pages/OnboardingPage.css @@ -20,3 +20,23 @@ font-weight: var(--font-weight-heading-lg); color: var(--color-text); } + +.tu-onboarding-page__complete { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-4); +} + +.tu-onboarding-page__complete-text { + font-size: var(--font-size-body); + color: var(--color-text-secondary); + margin: 0; +} + +/* .tu-button is a native-