Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/app/controllers/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion backend/tests/api/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/Icon/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
}
7 changes: 7 additions & 0 deletions frontend/src/components/Input/Input.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/components/Input/Input.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={['tu-field', error ? 'tu-field--error' : '', className].filter(Boolean).join(' ')}>
<label className="tu-field__label" htmlFor={fieldId}>
{label}
</label>
<div className="tu-field__label-row">
<label className="tu-field__label" htmlFor={fieldId}>
{label}
</label>
{labelExtra}
</div>
<input
id={fieldId}
className="tu-field__control"
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/features/auth/components/AuthForm.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,52 @@
color: var(--color-accent);
}

.tu-auth-form__error a {
color: var(--color-accent);
}

/* Text-button in the label row, not an icon overlaid on the field itself -- avoids coupling to
the input's exact pixel geometry (fragile under font-size overrides/zoom) and the icon is
never the only signal: it's always paired with the visible "Show"/"Hide" word. */
.tu-password-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-body-medium);
color: var(--color-accent);
cursor: pointer;
}

.tu-password-toggle:hover {
color: var(--color-accent-strong);
}

.tu-password-meter {
display: flex;
gap: var(--space-1);
margin-top: var(--space-1);
}

.tu-password-meter__segment {
flex: 1;
height: 3px;
border-radius: var(--radius-full);
background: var(--color-hairline);
transition: background var(--duration-fast) var(--ease-standard);
}

.tu-password-meter[data-tone='building'] .tu-password-meter__segment--filled {
background: var(--color-warning);
}

.tu-password-meter[data-tone='strong'] .tu-password-meter__segment--filled {
background: var(--color-success);
}

.tu-password-rules {
display: flex;
flex-direction: column;
Expand Down
57 changes: 53 additions & 4 deletions frontend/src/features/auth/components/RegisterForm.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '../../../components/Button'
import { Icon } from '../../../components/Icon'
import { Input } from '../../../components/Input'
import { useToast } from '../../../components/Toast'
import { getErrorMessage } from '../../../utils/apiErrorMessage.js'
Expand Down Expand Up @@ -39,10 +40,18 @@ export function RegisterForm() {
const [confirmPassword, setConfirmPassword] = useState('')
const [touched, setTouched] = useState({})
const [fieldErrors, setFieldErrors] = useState({})
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)

const isSubmitting = status === 'submitting'
const values = { email, password, confirmPassword }
const strength = describePassword(password)
// Real signal only, derived from the same describePassword() the checklist below already
// renders -- not a separate invented scale. 0 empty, 1 below the 8-char floor, 2 meets the
// floor but not the strength bar, 3 fully valid.
const strengthLevel =
password.length === 0 ? 0 : !strength.hasMinLength ? 1 : !strength.hasStrength ? 2 : 3
const strengthTone = strengthLevel === 3 ? 'strong' : strengthLevel > 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.
Expand Down Expand Up @@ -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 (
<form className="tu-auth-form" onSubmit={handleSubmit} noValidate>
Expand All @@ -121,7 +130,7 @@ export function RegisterForm() {
<div>
<Input
label="Password"
type="password"
type={showPassword ? 'text' : 'password'}
name="password"
id={PASSWORD_ID}
autoComplete="new-password"
Expand All @@ -130,8 +139,20 @@ export function RegisterForm() {
onBlur={handleBlur('password')}
error={fieldErrors.password}
aria-describedby={passwordDescribedBy}
labelExtra={
<PasswordToggle
shown={showPassword}
onToggle={() => setShowPassword((value) => !value)}
controls={PASSWORD_ID}
/>
}
required
/>
<div className="tu-password-meter" data-tone={strengthTone} aria-hidden="true">
<span className={`tu-password-meter__segment${strengthLevel >= 1 ? ' tu-password-meter__segment--filled' : ''}`} />
<span className={`tu-password-meter__segment${strengthLevel >= 2 ? ' tu-password-meter__segment--filled' : ''}`} />
<span className={`tu-password-meter__segment${strengthLevel >= 3 ? ' tu-password-meter__segment--filled' : ''}`} />
</div>
<ul className="tu-password-rules" id={PASSWORD_RULES_ID}>
<li className={`tu-password-rules__item${strength.hasMinLength ? ' tu-password-rules__item--met' : ''}`}>
<RuleIcon met={strength.hasMinLength} />
Expand All @@ -147,19 +168,32 @@ export function RegisterForm() {
</div>
<Input
label="Confirm password"
type="password"
type={showConfirmPassword ? 'text' : 'password'}
name="confirmPassword"
id={CONFIRM_PASSWORD_ID}
autoComplete="new-password"
value={confirmPassword}
onChange={handleChange('confirmPassword', setConfirmPassword)}
onBlur={handleBlur('confirmPassword')}
error={fieldErrors.confirmPassword}
labelExtra={
<PasswordToggle
shown={showConfirmPassword}
onToggle={() => setShowConfirmPassword((value) => !value)}
controls={CONFIRM_PASSWORD_ID}
/>
}
required
/>
{serverErrorMessage && (
<p className="tu-auth-form__error" role="alert">
{serverErrorMessage}
{isEmailTaken && (
<>
{' '}
<Link to="/login">Log in instead.</Link>
</>
)}
</p>
)}
<Button type="submit" loading={isSubmitting} disabled={isSubmitting}>
Expand All @@ -172,6 +206,21 @@ export function RegisterForm() {
)
}

function PasswordToggle({ shown, onToggle, controls }) {
return (
<button
type="button"
className="tu-password-toggle"
onClick={onToggle}
aria-pressed={shown}
aria-controls={controls}
>
<Icon name={shown ? 'eye-off' : 'eye'} size="sm" />
{shown ? 'Hide' : 'Show'}
</button>
)
}

function RuleIcon({ met }) {
return (
<svg className="tu-password-rules__icon" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
Expand Down
107 changes: 107 additions & 0 deletions frontend/src/features/auth/components/SignupBrandPanel.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* Deliberate one-time reversal of the app's normal ink-on-paper (design-system.md's own "vault,
not dashboard" concept) — using the semantic text/surface tokens directly, not hardcoded hex,
so this still reads as a reversal in dark mode (a light panel against a dark page) rather than
flattening into the same tone as the rest of a dark-mode app. */
.tu-signup-panel {
display: flex;
align-items: center;
height: 100%;
padding: var(--space-16) var(--space-8);
background: var(--color-text);
color: var(--color-surface);
}

.tu-signup-panel__content {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: var(--reading-max-width);
}

/* The system's one other sanctioned use of the serif display face beyond the balance figure
itself (see AppLayout.css's wordmark) — same rationale, extended to the first public surface
it appears on. */
.tu-signup-panel__wordmark {
font-family: var(--font-serif);
font-size: var(--font-size-display);
line-height: var(--line-height-display);
font-weight: 600;
letter-spacing: 0.01em;
margin: 0;
}

.tu-signup-panel__tagline {
font-size: var(--font-size-heading-sm);
line-height: var(--line-height-heading-sm);
font-weight: var(--font-weight-body);
margin: 0;
opacity: 0.86;
}

.tu-signup-panel__steps {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
}

.tu-signup-panel__step {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}

.tu-signup-panel__step-number {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
border: 1px solid currentColor;
opacity: 0.55;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-data-strong);
}

/* Accent is reserved for interactive elements and "current selection" (design-system.md §2.4) --
this is that second, sanctioned use, not decoration. */
.tu-signup-panel__step--current .tu-signup-panel__step-number {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-text);
opacity: 1;
}

.tu-signup-panel__step-body {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 1px;
}

.tu-signup-panel__step-title {
font-size: var(--font-size-body);
font-weight: var(--font-weight-body-medium);
margin: 0;
}

.tu-signup-panel__step--current .tu-signup-panel__step-title {
font-weight: var(--font-weight-heading-sm);
}

.tu-signup-panel__step-description {
font-size: var(--font-size-caption);
line-height: var(--line-height-caption);
margin: 0;
opacity: 0.75;
}

.tu-signup-panel__trust {
font-size: var(--font-size-caption);
margin: 0;
opacity: 0.6;
}
Loading
Loading