Auth Card
A centered, minimal sign-in card with serif title, email and password, loading lock, shake error, OAuth and a Turnstile slot.
Hint: use the password mioui to sign in; anything else shows an error.
'use client';
import { AuthCard } from '@/components/blocks/auth-card';
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export default function AuthCardDemo() {
return (
<div className="flex w-full flex-col items-center gap-3">
<AuthCard
lang="en"
onSubmit={async ({ password }) => {
await wait(1000);
if (password !== 'mioui') throw new Error('Incorrect email or password. Please try again.');
}}
/>
<p className="text-[12px] text-text-muted">
Hint: use the password <code className="font-mono">mioui</code> to sign in; anything else shows an error.
</p>
</div>
);
}
Includes
- Serif title and subtitle on a 12px-radius card
- Email field,
PasswordInput(eye toggle), remember me, forgot-password link - Submit turns into a
Spinner+ “Signing in…” during async submit and locks the whole form via<fieldset disabled> - Error banner: GENTLE height reveal plus a horizontal shake (skipped under reduced motion),
role="alert" - GitHub / Google OAuth buttons with an “or” divider (borderless buttons inside the card — single-border discipline)
- Cloudflare Turnstile placeholder (300×65 dashed box); pass the real widget via
turnstile - Success state with a BOUNCY check pop-in
Usage
import { AuthCard } from "@/components/blocks/auth-card";
<AuthCard
lang="en"
onSubmit={async ({ email, password, remember }) => {
const res = await signIn(email, password, remember);
if (!res.ok) throw new Error("Incorrect email or password"); // throw → shaking error banner
}}
onOAuth={(provider) => startOAuth(provider)} // 'github' | 'google'
forgotHref="/forgot"
turnstile={<Turnstile siteKey="…" />} // placeholder when omitted
/>| Prop | Type | Notes |
|---|---|---|
lang | 'zh' | 'en' | Built-in copy language, default zh |
onSubmit | (v) => Promise<void> | Reject shows the error, resolve shows success |
onOAuth | (p) => void | Third-party sign-in |
turnstile / hideTurnstile | ReactNode / boolean | Bot-check slot |
Shared parts (OAuthButtons, OrDivider, ShakeError, TurnstileSlot, OtpInput) live in components/blocks/auth-parts.tsx.
Source
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowLeft, CheckCircle } from '@phosphor-icons/react';
import { useId, useState, type FormEvent, type ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Field, Input, Label, PasswordInput } from '@/components/ui/input';
import { Spinner } from '@/components/ui/feedback';
import { SPRING_BOUNCY, SPRING_GENTLE } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { OAuthButtons, OrDivider, ShakeError, TurnstileSlot, type OAuthProvider } from './auth-parts';
const COPY = {
zh: {
title: '欢迎回来',
subtitle: '登录你的 MioUI 账户',
email: '邮箱',
emailPh: 'you@example.com',
password: '密码',
passwordPh: '输入密码',
show: '显示密码',
hide: '隐藏密码',
remember: '记住我',
forgot: '忘记密码?',
submit: '登录',
submitting: '登录中…',
or: '或',
github: 'GitHub',
google: 'Google',
noAccount: '还没有账户?',
signup: '注册',
successTitle: '登录成功',
successDesc: (e: string) => `已以 ${e} 登录。`,
back: '返回',
fallbackError: '登录失败,请稍后再试。',
required: '请填写邮箱和密码。',
},
en: {
title: 'Welcome back',
subtitle: 'Sign in to your MioUI account',
email: 'Email',
emailPh: 'you@example.com',
password: 'Password',
passwordPh: 'Enter your password',
show: 'Show password',
hide: 'Hide password',
remember: 'Remember me',
forgot: 'Forgot password?',
submit: 'Sign in',
submitting: 'Signing in…',
or: 'or',
github: 'GitHub',
google: 'Google',
noAccount: 'No account yet?',
signup: 'Sign up',
successTitle: 'Signed in',
successDesc: (e: string) => `You are signed in as ${e}.`,
back: 'Back',
fallbackError: 'Sign-in failed. Please try again.',
required: 'Please enter your email and password.',
},
};
export interface AuthCredentials {
email: string;
password: string;
remember: boolean;
}
export interface AuthCardProps {
lang?: 'zh' | 'en';
/** 异步提交;reject(Error) 时显示抖动错误横幅,resolve 时进入成功态 */
onSubmit?: (v: AuthCredentials) => Promise<void> | void;
onOAuth?: (p: OAuthProvider) => void;
forgotHref?: string;
onForgot?: () => void;
signupHref?: string;
/** 真实的 Turnstile 组件;省略时显示 300×65 占位框 */
turnstile?: ReactNode;
/** 关闭 Turnstile 占位 */
hideTurnstile?: boolean;
className?: string;
}
/** 居中极简登录卡片:衬线标题、邮箱 + 密码、记住我、忘记密码、加载锁定、抖动错误、OAuth、Turnstile 槽位 */
export function AuthCard({
lang = 'zh',
onSubmit,
onOAuth,
forgotHref = '#',
onForgot,
signupHref = '#',
turnstile,
hideTurnstile,
className,
}: AuthCardProps) {
const t = COPY[lang];
const id = useId();
const reduce = useReducedMotionPref();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [remember, setRemember] = useState(true);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [shake, setShake] = useState(0);
const [done, setDone] = useState(false);
const fail = (msg: string) => {
setError(msg);
setShake((n) => n + 1);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (pending) return;
if (!email.trim() || !password) return fail(t.required);
setError(null);
setPending(true);
try {
await onSubmit?.({ email: email.trim(), password, remember });
setPending(false);
setDone(true);
} catch (err) {
setPending(false);
fail(err instanceof Error && err.message ? err.message : t.fallbackError);
}
};
const swap = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
: { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6, transition: { duration: 0.12 } }, transition: SPRING_GENTLE };
return (
<Card className={twMerge('w-full max-w-[380px] p-7', className)}>
<AnimatePresence mode="wait" initial={false}>
{done ? (
<motion.div key="done" {...swap} className="flex flex-col items-center py-6 text-center" aria-live="polite">
<motion.span
initial={reduce ? false : { scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={reduce ? { duration: 0 } : SPRING_BOUNCY}
className="mb-4 inline-flex size-12 items-center justify-center rounded-full bg-success/12 text-success"
>
<CheckCircle size={26} weight="fill" />
</motion.span>
<h2 className="font-title text-[22px] leading-snug text-text-primary">{t.successTitle}</h2>
<p className="mt-1.5 text-sm text-text-secondary">{t.successDesc(email.trim())}</p>
<Button
variant="ghost"
size="sm"
className="mt-6"
onClick={() => {
setDone(false);
setPassword('');
}}
>
<ArrowLeft />
{t.back}
</Button>
</motion.div>
) : (
<motion.form key="form" {...swap} onSubmit={submit} noValidate aria-busy={pending || undefined}>
<fieldset disabled={pending} className="m-0 min-w-0 border-0 p-0">
<div className="mb-6 text-center">
<h2 className="font-title text-[26px] leading-tight text-text-primary">{t.title}</h2>
<p className="mt-1.5 text-sm text-text-secondary">{t.subtitle}</p>
</div>
<div className="flex flex-col gap-4">
<ShakeError message={error} shakeKey={shake} />
<Field>
<Label htmlFor={`${id}-email`}>{t.email}</Label>
<Input
id={`${id}-email`}
type="email"
size="lg"
autoComplete="email"
placeholder={t.emailPh}
value={email}
onChange={(e) => setEmail(e.target.value)}
invalid={!!error && !email.trim()}
/>
</Field>
<Field>
<div className="flex items-center justify-between">
<Label htmlFor={`${id}-pwd`}>{t.password}</Label>
<a
href={forgotHref}
onClick={(e) => {
if (onForgot) {
e.preventDefault();
onForgot();
}
}}
className="cds-focus rounded-sm text-[12px] text-text-secondary transition-colors duration-[120ms] hover:text-accent-text"
>
{t.forgot}
</a>
</div>
<PasswordInput
id={`${id}-pwd`}
size="lg"
placeholder={t.passwordPh}
value={password}
onChange={(e) => setPassword(e.target.value)}
showLabel={t.show}
hideLabel={t.hide}
invalid={!!error}
disabled={pending}
/>
</Field>
<label className="inline-flex w-fit cursor-pointer select-none items-center gap-2 text-[13px] text-text-secondary">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="cds-focus size-4 rounded-sm accent-accent-brand"
/>
{t.remember}
</label>
{!hideTurnstile && <TurnstileSlot>{turnstile}</TurnstileSlot>}
<Button type="submit" size="lg" className="w-full" aria-busy={pending || undefined}>
{pending && <Spinner size="sm" label={t.submitting} />}
{pending ? t.submitting : t.submit}
</Button>
<OrDivider>{t.or}</OrDivider>
<OAuthButtons onSelect={onOAuth} disabled={pending} githubLabel={t.github} googleLabel={t.google} />
</div>
<p className="mt-6 text-center text-[13px] text-text-secondary">
{t.noAccount}{' '}
<a href={signupHref} className="cds-focus rounded-sm font-medium text-accent-text underline-offset-4 hover:underline">
{t.signup}
</a>
</p>
</fieldset>
</motion.form>
)}
</AnimatePresence>
</Card>
);
}
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { GithubLogo, GoogleLogo, WarningCircle } from '@phosphor-icons/react';
import {
useEffect,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/components/ui/button';
import { SPRING_GENTLE, SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
/* ==========================================================================
认证区块共享零件:OAuthButtons / OrDivider / ShakeError / TurnstileSlot / OtpInput
========================================================================== */
export type OAuthProvider = 'github' | 'google';
/** 第三方登录按钮:卡片内部用 secondary(无边框,单线框纪律) */
export function OAuthButtons({
onSelect,
disabled,
githubLabel = 'GitHub',
googleLabel = 'Google',
className,
}: {
onSelect?: (p: OAuthProvider) => void;
disabled?: boolean;
githubLabel?: ReactNode;
googleLabel?: ReactNode;
className?: string;
}) {
return (
<div className={twMerge('grid grid-cols-2 gap-2', className)}>
<Button type="button" variant="secondary" size="lg" className="text-sm" disabled={disabled} onClick={() => onSelect?.('github')}>
<GithubLogo weight="fill" />
{githubLabel}
</Button>
<Button type="button" variant="secondary" size="lg" className="text-sm" disabled={disabled} onClick={() => onSelect?.('google')}>
<GoogleLogo weight="bold" />
{googleLabel}
</Button>
</div>
);
}
/** “或” 分隔线:两侧发丝线 */
export function OrDivider({ children = 'or', className }: { children?: ReactNode; className?: string }) {
return (
<div role="separator" className={twMerge('flex items-center gap-3 text-[12px] text-text-muted', className)}>
<span className="h-px flex-1 bg-hairline" />
<span>{children}</span>
<span className="h-px flex-1 bg-hairline" />
</div>
);
}
/**
* 错误横幅:出现时 GENTLE 高度展开;每次 shakeKey 变化左右抖动一次(减弱动效时不抖)。
* role="alert" 读屏即时播报。message 为空时收起。
*/
export function ShakeError({ message, shakeKey, className }: { message?: ReactNode; shakeKey?: number | string; className?: string }) {
const reduce = useReducedMotionPref();
return (
<AnimatePresence initial={false}>
{message ? (
<motion.div
key="err"
initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, height: 'auto' }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={reduce ? { duration: 0 } : SPRING_GENTLE}
className="overflow-hidden"
>
<motion.div
key={shakeKey}
role="alert"
initial={reduce ? false : { x: 0 }}
animate={reduce ? undefined : { x: [0, -7, 7, -5, 5, -2, 0] }}
transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }}
className={twMerge(
'flex items-start gap-2 rounded-lg bg-danger/10 px-3 py-2 text-[13px] leading-snug text-danger [&_svg]:mt-px [&_svg]:size-4 [&_svg]:shrink-0',
className,
)}
>
<WarningCircle weight="fill" />
<span className="min-w-0 flex-1">{message}</span>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
/**
* Cloudflare Turnstile 占位:300×65 虚线发丝框。传 children 时直接渲染真实组件。
*/
export function TurnstileSlot({ children, label = 'Cloudflare Turnstile', className }: { children?: ReactNode; label?: ReactNode; className?: string }) {
if (children) return <div className={twMerge('flex justify-center', className)}>{children}</div>;
return (
<div className={twMerge('flex justify-center', className)}>
<div
aria-hidden
className="flex h-[65px] w-full max-w-[300px] items-center justify-center rounded-lg border border-dashed border-hairline-strong text-[12px] text-text-muted"
>
{label}
</div>
</div>
);
}
/**
* 6 位验证码输入:自动前进、退格回退、←→ 移动、整段粘贴、仅数字。
*/
export function OtpInput({
value,
onChange,
onComplete,
length = 6,
disabled,
invalid,
autoFocus,
'aria-label': ariaLabel = 'Verification code',
digitLabel = (i: number) => `Digit ${i}`,
className,
}: {
value: string;
onChange: (v: string) => void;
onComplete?: (v: string) => void;
length?: number;
disabled?: boolean;
invalid?: boolean;
autoFocus?: boolean;
'aria-label'?: string;
digitLabel?: (i: number) => string;
className?: string;
}) {
const refs = useRef<(HTMLInputElement | null)[]>([]);
const reduce = useReducedMotionPref();
const chars = Array.from({ length }, (_, i) => value[i] ?? '');
// 最新值(commit 后立即更新),供 onFocus 在父组件重渲染前判断
const latest = useRef(value);
latest.current = value;
useEffect(() => {
if (!autoFocus) return;
const id = requestAnimationFrame(() => refs.current[Math.min(value.length, length - 1)]?.focus());
return () => cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoFocus]);
const focusAt = (i: number) => {
const el = refs.current[Math.max(0, Math.min(length - 1, i))];
el?.focus();
el?.select();
};
const commit = (next: string) => {
const v = next.replace(/\D/g, '').slice(0, length);
latest.current = v;
onChange(v);
if (v.length === length) onComplete?.(v);
};
const setAt = (i: number, d: string) => {
const arr = chars.slice();
arr[i] = d;
// 不允许中间留空:截断到第一个空位
const joined = arr.join('');
commit(joined);
};
const onKey = (i: number) => (e: KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Backspace') {
e.preventDefault();
if (chars[i]) {
commit(value.slice(0, i) + value.slice(i + 1));
focusAt(i);
} else if (i > 0) {
commit(value.slice(0, i - 1) + value.slice(i));
focusAt(i - 1);
}
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
focusAt(i - 1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
focusAt(Math.min(i + 1, value.length));
} else if (/^\d$/.test(e.key)) {
e.preventDefault();
const pos = Math.min(i, value.length);
if (pos < value.length) setAt(pos, e.key);
else commit(value + e.key);
focusAt(pos + 1);
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
}
};
const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
const digits = e.clipboardData.getData('text').replace(/\D/g, '');
if (!digits) return;
e.preventDefault();
commit(digits);
focusAt(Math.min(digits.length, length - 1));
};
return (
<div role="group" aria-label={ariaLabel} className={twMerge('flex justify-center gap-2', className)}>
{chars.map((c, i) => {
const filled = c !== '';
return (
<div key={i} className="relative">
<input
ref={(el) => {
refs.current[i] = el;
}}
value={c}
inputMode="numeric"
autoComplete={i === 0 ? 'one-time-code' : 'off'}
maxLength={1}
disabled={disabled}
aria-label={digitLabel(i + 1)}
aria-invalid={invalid || undefined}
onKeyDown={onKey(i)}
onPaste={onPaste}
// 移动端软键盘 / 自动填充可能绕过 keydown,这里兜底
onChange={(e) => {
const d = e.target.value.replace(/\D/g, '');
if (!d) return;
if (d.length > 1) {
commit(value.slice(0, i) + d);
focusAt(i + d.length);
} else {
setAt(Math.min(i, value.length), d);
focusAt(i + 1);
}
}}
onFocus={(e) => {
// 只允许落在第一个空位或已填位置
if (i > latest.current.length) focusAt(latest.current.length);
else e.currentTarget.select();
}}
className={twMerge(
'cds-focus size-11 rounded-lg bg-surface-3 text-center font-mono text-[18px] tabular-nums text-text-primary caret-accent-brand outline-none',
'shadow-[inset_0_0_0_1px_var(--cds-border)] transition-[box-shadow,background-color] duration-[120ms] ease-cds hover:shadow-[inset_0_0_0_1px_var(--cds-border-strong)]',
'focus:shadow-focus disabled:opacity-50',
invalid && 'shadow-[inset_0_0_0_1px_var(--cds-danger)] hover:shadow-[inset_0_0_0_1px_var(--cds-danger)]',
)}
/>
{filled && !reduce && (
<motion.span
key={c + i}
aria-hidden
initial={{ scale: 0.6, opacity: 0.5 }}
animate={{ scale: 1, opacity: 0 }}
transition={SPRING_SNAPPY}
className="pointer-events-none absolute inset-0 rounded-lg shadow-[inset_0_0_0_1.5px_var(--cds-accent)]"
/>
)}
</div>
);
})}
</div>
);
}
/** 重新发送倒计时:start(60) 开始,left 为剩余秒数(0 表示可重发) */
export function useCountdown() {
const [left, setLeft] = useState(0);
useEffect(() => {
if (left <= 0) return;
const id = setTimeout(() => setLeft((n) => n - 1), 1000);
return () => clearTimeout(id);
}, [left]);
return { left, start: (s: number) => setLeft(s), reset: () => setLeft(0) };
}