mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 22:17:50 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user