feat(auth): redesign login, MFA, and setup surfaces with cockpit voice (#714)

* feat(auth): redesign login, MFA, and setup surfaces with cockpit voice

Applies the cockpit design system to every auth surface: Login, MFA
challenge, first-boot Setup, and the three MFA dialogs (Enroll, Backup
Codes, Disable). Introduces shared primitives under components/auth/:
AuthCanvas shell with bevelled card and cyan left rail, AuthStepHeader
for tracked-mono kicker plus italic hero pair, OtpDigitField with six
recessed digit cells and auto-submit, and ErrorRail for consistent
inline errors.

Preserves all behaviour: Local/LDAP toggle, dynamic SSO providers,
auto-submit TOTP, backup-code fallback with dash formatting, rate-limit
countdown, three-step enrollment, cold-start setup. Surface-only change;
AuthContext, routing, and endpoints untouched.

* test(e2e): update MFA spec selectors to match redesigned auth surfaces

The auth redesign renamed buttons, restyled the backup-mode toggle to
bracketed mono, changed input ids on the challenge + disable dialogs,
and made the challenge TOTP path auto-submit (no explicit Verify
button). Updates the spec accordingly:

- Enroll step 1 button: Next -> Continue
- Enroll step 3 button: "saved these" -> Done
- Challenge heading: "Two-factor authentication" -> "Verify"
- Backup toggle: "Use a backup code instead" -> "Use backup code"
- Challenge verify button: "Verify and sign in" -> "Verify"
- Challenge input id: #mfa-code -> #mfa-otp (TOTP) / #mfa-backup (backup)
- Disable dialog backup input id: #mfa-disable-code -> #mfa-disable-backup

All six MFA tests pass locally. No production code changed.
This commit is contained in:
Anso
2026-04-20 17:58:57 -04:00
committed by GitHub
parent d95e154aeb
commit 0a0198013d
11 changed files with 1076 additions and 623 deletions
@@ -0,0 +1,46 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface AuthCanvasProps extends React.HTMLAttributes<HTMLDivElement> {
footer?: React.ReactNode;
}
export function AuthCanvas({ children, className, footer, ...props }: AuthCanvasProps) {
return (
<div
className={cn(
'relative flex min-h-svh flex-col items-center justify-center px-4 py-10 sm:px-6',
className,
)}
{...props}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 [background:radial-gradient(circle_at_50%_-10%,oklch(0.78_0.11_195_/_0.10),transparent_55%)]"
/>
<div
role="group"
className="relative w-full max-w-[440px] animate-scale-in overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel"
style={{ animationDuration: 'var(--duration-base)', animationTimingFunction: 'var(--ease-out-expo)' }}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-brand/70" />
<div className="flex items-center justify-between border-b border-card-border/60 px-7 pt-6 pb-4">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
SENCHO
</span>
<span className="h-1.5 w-1.5 rounded-full bg-brand shadow-[0_0_8px_0_oklch(0.78_0.11_195_/_0.6)]" />
</div>
<div className="px-7 pb-7 pt-6">{children}</div>
{footer && (
<div className="border-t border-card-border/60 px-7 py-3 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{footer}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface AuthStepHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
kicker: string;
hero: string;
caption?: React.ReactNode;
}
export function AuthStepHeader({ kicker, hero, caption, className, ...props }: AuthStepHeaderProps) {
return (
<div className={cn('flex flex-col gap-1.5', className)} {...props}>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{kicker}
</span>
<h1 className="font-display text-[2.25rem] italic leading-[1.05] text-stat-value">
{hero}
</h1>
{caption && (
<p className="text-sm leading-snug text-stat-subtitle">{caption}</p>
)}
</div>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
export function ErrorRail({ children }: { children: ReactNode }) {
return (
<div className="relative overflow-hidden rounded-md border border-destructive/30 bg-destructive/8 pl-4 pr-3 py-2.5">
<span className="absolute inset-y-0 left-0 w-[3px] bg-destructive/70" aria-hidden />
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-destructive">Error</div>
<div className="text-sm leading-snug text-stat-value">{children}</div>
</div>
);
}
@@ -0,0 +1,93 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
type OtpState = 'idle' | 'loading' | 'success' | 'error';
interface OtpDigitFieldProps {
value: string;
onChange: (value: string) => void;
length?: number;
state?: OtpState;
disabled?: boolean;
autoFocus?: boolean;
id?: string;
ariaLabel?: string;
}
export function OtpDigitField({
value,
onChange,
length = 6,
state = 'idle',
disabled = false,
autoFocus = false,
id,
ariaLabel = '6-digit verification code',
}: OtpDigitFieldProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [focused, setFocused] = React.useState(false);
React.useEffect(() => {
if (autoFocus && inputRef.current && !disabled) inputRef.current.focus();
}, [autoFocus, disabled]);
const activeIndex = Math.min(value.length, length - 1);
const isSuccess = state === 'success';
const isError = state === 'error';
const isLoading = state === 'loading';
return (
<div
role="group"
aria-label={ariaLabel}
className={cn(
'relative flex w-full items-stretch gap-2',
disabled && 'pointer-events-none opacity-60',
)}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef}
id={id}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={length}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="absolute inset-0 h-full w-full cursor-text opacity-0"
/>
{Array.from({ length }).map((_, i) => {
const char = value[i] ?? '';
const filled = char !== '';
const isActive = focused && !disabled && i === activeIndex;
return (
<div
key={i}
aria-hidden
className={cn(
'relative flex h-14 flex-1 items-center justify-center rounded-md border bg-background transition-colors',
'shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]',
'font-mono tabular-nums text-2xl',
isError
? 'border-destructive/60 text-destructive'
: isSuccess
? 'border-brand/60 text-brand'
: isActive
? 'border-brand/70 text-stat-value shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4),0_0_0_3px_oklch(0.78_0.11_195_/_0.22)]'
: filled
? 'border-card-border-top text-stat-value'
: 'border-card-border text-stat-subtitle',
isLoading && 'opacity-70',
)}
>
{char || (isActive ? <span className="h-5 w-[1.5px] animate-pulse bg-brand" /> : null)}
</div>
);
})}
</div>
);
}