Add authenticated login and SSO protection

This commit is contained in:
inman
2026-05-29 15:54:13 +08:00
parent e36f28a668
commit 0648874801
50 changed files with 1853 additions and 63 deletions

View File

@@ -6,11 +6,15 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Archive,
LogIn,
LogOut,
Settings,
Sparkles
Sparkles,
UserCircle
} from "lucide-react";
import clsx from "clsx";
import { revealChildren, runScopedMotion } from "@/lib/ui/motion";
import type { AuthUser } from "@/lib/auth/session";
const nav = [
{ href: "/create", label: "创作", icon: Sparkles },
@@ -18,45 +22,76 @@ const nav = [
{ href: "/settings", label: "设置", icon: Settings }
];
export function AppShell({ children }: { children: React.ReactNode }) {
export function AppShell({
children,
user,
authRequired
}: {
children: React.ReactNode;
user?: AuthUser | null;
authRequired?: boolean;
}) {
const pathname = usePathname();
const shellRef = useRef<HTMLDivElement | null>(null);
const isAuthPage = pathname.startsWith("/auth");
useEffect(() => {
return runScopedMotion(shellRef, (scope) => revealChildren(scope, "[data-shell-animate]"));
}, []);
return (
<div className="app-shell" ref={shellRef}>
<a className="skip-link" href="#main-content"></a>
<header className="topbar" data-shell-animate>
<Link className="brand" href="/create" aria-label="智念AIGC平台">
<span className="brand-mark" aria-hidden="true">
<Image className="brand-logo-img" src="/logo/zhinian-logo.png" alt="" width={124} height={28} priority />
</span>
<div>
<div className="brand-title">AIGC平台</div>
</div>
</Link>
<nav className="nav top-nav" aria-label="主导航">
{nav.map((item) => {
const Icon = item.icon;
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={clsx("nav-link", active && "active")}
aria-current={active ? "page" : undefined}
>
<Icon aria-hidden="true" />
<span>{item.label}</span>
<div className={clsx("app-shell", isAuthPage && "auth-shell")} ref={shellRef}>
{!isAuthPage ? <a className="skip-link" href="#main-content"></a> : null}
{!isAuthPage ? (
<header className="topbar" data-shell-animate>
<Link className="brand" href="/create" aria-label="智念AIGC平台">
<span className="brand-mark" aria-hidden="true">
<Image className="brand-logo-img" src="/logo/zhinian-logo.png" alt="" width={124} height={28} priority />
</span>
<div>
<div className="brand-title">AIGC平台</div>
</div>
</Link>
<nav className="nav top-nav" aria-label="主导航">
{nav.map((item) => {
const Icon = item.icon;
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={clsx("nav-link", active && "active")}
aria-current={active ? "page" : undefined}
>
<Icon aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="topbar-account">
{user ? (
<>
<span className="account-chip" title={user.username || user.subject}>
<UserCircle aria-hidden="true" />
<span>{user.displayName}</span>
</span>
<form action="/api/auth/logout" method="post">
<button className="icon-button logout-button" type="submit" aria-label="退出登录" title="退出登录">
<LogOut aria-hidden="true" />
</button>
</form>
</>
) : authRequired ? (
<Link className="nav-link login-link" href="/auth/login">
<LogIn aria-hidden="true" />
<span></span>
</Link>
);
})}
</nav>
</header>
<main className="main" id="main-content" tabIndex={-1}>{children}</main>
) : null}
</div>
</header>
) : null}
<main className={clsx("main", isAuthPage && "auth-main")} id="main-content" tabIndex={-1}>{children}</main>
</div>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent } from "react";
import Image from "next/image";
import { Loader2, LogIn, RefreshCw } from "lucide-react";
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
export function AuthLoginPanel({
next,
configured,
message,
missing
}: {
next: string;
configured: boolean;
message?: string | null;
missing?: string[];
}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [randomStr, setRandomStr] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const screenRef = useRef<HTMLDivElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
const hasMissingConfig = !configured && Boolean(missing?.length);
useEffect(() => {
refreshCaptcha();
}, []);
useEffect(() => {
return runScopedMotion(screenRef, (scope) => revealChildren(scope));
}, []);
useEffect(() => {
pulseFeedback(feedbackRef.current);
}, [error, message, missing?.join(",")]);
const captchaSrc = useMemo(() => {
if (!randomStr) return "";
return `/api/auth/captcha?randomStr=${encodeURIComponent(randomStr)}`;
}, [randomStr]);
function refreshCaptcha() {
setCode("");
setRandomStr(crypto.randomUUID());
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!configured || submitting) return;
setSubmitting(true);
setError(null);
try {
const response = await fetch("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, code, randomStr, next })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "登录失败");
window.location.assign(payload.redirectTo || next || "/create");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
refreshCaptcha();
} finally {
setSubmitting(false);
}
}
return (
<div className="auth-page" ref={screenRef}>
<section className="auth-brand-panel" aria-label="智念AIGC平台" data-animate>
<Image className="auth-brand-logo" src="/logo/zhinian-logo.png" alt="" width={168} height={38} priority />
<h1>AIGC平台</h1>
</section>
<section className="panel auth-panel" data-animate>
<h2></h2>
{message || hasMissingConfig || error ? (
<div ref={feedbackRef}>
{message ? <div className="callout" role="alert">{message}</div> : null}
{hasMissingConfig ? (
<div className="callout" role="alert">
{missing?.join("、")}
</div>
) : null}
{error ? <div className="callout" role="alert">{error}</div> : null}
</div>
) : null}
<form className="auth-password-form" onSubmit={submit}>
<label className="field" data-animate>
<span></span>
<input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={!configured || submitting}
placeholder="请输入账号"
/>
</label>
<label className="field" data-animate>
<span></span>
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={!configured || submitting}
placeholder="请输入密码"
/>
</label>
<label className="field" data-animate>
<span></span>
<div className="auth-captcha-row">
<input
autoComplete="off"
value={code}
onChange={(event) => setCode(event.target.value)}
disabled={!configured || submitting}
placeholder="请输入验证码"
/>
<button className="auth-captcha-button" type="button" onClick={refreshCaptcha} disabled={!configured || submitting} aria-label="刷新验证码" title="刷新验证码">
{captchaSrc ? <img src={captchaSrc} alt="验证码" /> : <RefreshCw size={18} />}
</button>
</div>
</label>
<button className="button primary auth-submit" type="submit" disabled={!configured || submitting || !username.trim() || !password || !code.trim()} data-animate>
{submitting ? <Loader2 className="spin" size={18} /> : <LogIn size={18} />}
</button>
</form>
</section>
</div>
);
}

View File

@@ -68,7 +68,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
const [videoResolution, setVideoResolution] = useState("720p");
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const [activeMentionIndex, setActiveMentionIndex] = useState<number | null>(null);
const [promptScrollTop, setPromptScrollTop] = useState(0);
const [materialPage, setMaterialPage] = useState(1);
const studioRef = useRef<HTMLDivElement | null>(null);
@@ -90,7 +90,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
}, [materials, mentionState]);
useEffect(() => {
setActiveMentionIndex(0);
setActiveMentionIndex(null);
}, [mentionState?.query, mentionSuggestions.length]);
useEffect(() => {
@@ -240,15 +240,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
if (!mentionSuggestions.length) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveMentionIndex((index) => (index + 1) % mentionSuggestions.length);
setActiveMentionIndex((index) => index === null ? 0 : (index + 1) % mentionSuggestions.length);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveMentionIndex((index) => (index - 1 + mentionSuggestions.length) % mentionSuggestions.length);
setActiveMentionIndex((index) => index === null ? mentionSuggestions.length - 1 : (index - 1 + mentionSuggestions.length) % mentionSuggestions.length);
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
selectMention(mentionSuggestions[activeMentionIndex] || mentionSuggestions[0]);
selectMention(mentionSuggestions[activeMentionIndex ?? 0] || mentionSuggestions[0]);
}
}

View File

@@ -28,6 +28,7 @@ type SettingsPayload = {
visual: string;
evolink: string;
seedance: string;
auth: string;
data: string;
};
capabilities: Array<{
@@ -219,6 +220,7 @@ export function SettingsPanel() {
<ServiceBadge label="即梦" value={payload?.modes.visual === "real" ? "真实接口" : "Mock"} ready={payload?.modes.visual === "real"} />
<ServiceBadge label="EvoLink" value={payload?.modes.evolink === "real" ? "真实接口" : "Mock"} ready={payload?.modes.evolink === "real"} />
<ServiceBadge label="Seedance" value={payload?.modes.seedance === "real" ? "真实接口" : "Mock"} ready={payload?.modes.seedance === "real"} />
<ServiceBadge label="账户" value={authModeLabel(payload?.modes.auth)} ready={payload?.modes.auth === "configured"} />
<ServiceBadge label="数据层" value={payload?.modes.data === "supabase" ? "Supabase" : "本地 JSON"} ready={false} />
</div>
@@ -282,7 +284,14 @@ function ServiceBadge({ label, value, ready }: { label: string; value: string; r
);
}
function authModeLabel(mode?: string) {
if (mode === "configured") return "已启用";
if (mode === "missing") return "待配置";
return "未启用";
}
function shortGroupLabel(id: string, title: string) {
if (id === "auth") return "登录";
if (id === "visual") return "图片";
if (id === "evolink") return "EvoLink";
if (id === "seedance") return "视频";