Add authenticated login and SSO protection
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
142
components/auth-login-panel.tsx
Normal file
142
components/auth-login-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "视频";
|
||||
|
||||
Reference in New Issue
Block a user