refactor: serve static frontend with Go APIs
This commit is contained in:
@@ -16,8 +16,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||||
import type { AuthUser } from "@/lib/auth/session";
|
||||
import { AccountUsageMenu } from "@/components/account-usage-menu";
|
||||
import { useBrowserAuth } from "@/components/browser-auth";
|
||||
|
||||
const nav = [
|
||||
{ href: "/create", label: "创作", icon: Sparkles },
|
||||
@@ -28,19 +28,8 @@ const nav = [
|
||||
{ href: "/billing", label: "计费", icon: WalletCards }
|
||||
];
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
user,
|
||||
authRequired,
|
||||
isAdmin,
|
||||
isSuperAdmin
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
user?: AuthUser | null;
|
||||
authRequired?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isSuperAdmin?: boolean;
|
||||
}) {
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { user, authRequired, isAdmin, isSuperAdmin } = useBrowserAuth();
|
||||
const pathname = usePathname();
|
||||
const shellRef = useRef<HTMLDivElement | null>(null);
|
||||
const isAuthPage = pathname.startsWith("/auth");
|
||||
|
||||
@@ -5,37 +5,47 @@ import type { FormEvent } from "react";
|
||||
import Image from "next/image";
|
||||
import { Loader2, LogIn } from "lucide-react";
|
||||
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||||
import { useBrowserAuth } from "@/components/browser-auth";
|
||||
import { safeBrowserNext } from "@/lib/client/browser-auth";
|
||||
|
||||
export function AuthLoginPanel({
|
||||
next,
|
||||
configured,
|
||||
message,
|
||||
missing,
|
||||
title = "账户登录",
|
||||
submitLabel = "登录"
|
||||
}: {
|
||||
next: string;
|
||||
configured: boolean;
|
||||
message?: string | null;
|
||||
missing?: string[];
|
||||
title?: string;
|
||||
submitLabel?: string;
|
||||
}) {
|
||||
const auth = useBrowserAuth();
|
||||
const [phone, setPhone] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [next, setNext] = useState("/create");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const screenRef = useRef<HTMLDivElement | null>(null);
|
||||
const feedbackRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasMissingConfig = !configured && Boolean(missing?.length);
|
||||
const configured = auth.status === "ready" && auth.authConfigured;
|
||||
const hasMissingConfig = auth.status === "ready" && auth.authRequired && !auth.authConfigured;
|
||||
|
||||
useEffect(() => {
|
||||
return runScopedMotion(screenRef, (scope) => revealChildren(scope));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setNext(safeBrowserNext(params.get("next")));
|
||||
const errorCode = params.get("error");
|
||||
if (errorCode) setMessage(errorMessages[errorCode] || "登录失败,请重新登录。");
|
||||
else if (params.get("loggedOut") === "1") setMessage("已退出登录。");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.status === "ready" && auth.user) window.location.replace(next);
|
||||
}, [auth.status, auth.user, next]);
|
||||
|
||||
useEffect(() => {
|
||||
pulseFeedback(feedbackRef.current);
|
||||
}, [error, message, missing?.join(",")]);
|
||||
}, [error, message, hasMissingConfig]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -58,7 +68,7 @@ export function AuthLoginPanel({
|
||||
});
|
||||
const result = await response.json().catch(() => ({})) as { error?: string; redirectTo?: string };
|
||||
if (!response.ok) throw new Error(result.error || "登录失败");
|
||||
window.location.assign(result.redirectTo || next || "/create");
|
||||
window.location.assign(safeBrowserNext(result.redirectTo, next));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -76,14 +86,15 @@ export function AuthLoginPanel({
|
||||
<section className="panel auth-panel" data-animate>
|
||||
<h2>{title}</h2>
|
||||
|
||||
{message || hasMissingConfig || error ? (
|
||||
{message || hasMissingConfig || auth.status === "error" || error ? (
|
||||
<div ref={feedbackRef}>
|
||||
{message ? <div className="callout" role="alert">{message}</div> : null}
|
||||
{hasMissingConfig ? (
|
||||
<div className="callout" role="alert">
|
||||
缺少配置:{missing?.join("、")}
|
||||
账号认证配置不完整,请联系管理员。
|
||||
</div>
|
||||
) : null}
|
||||
{auth.status === "error" ? <div className="callout" role="alert">{auth.error || "无法读取认证状态。"}</div> : null}
|
||||
{error ? <div className="callout" role="alert">{error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -128,3 +139,9 @@ export function AuthLoginPanel({
|
||||
function formStringValue(value: FormDataEntryValue | null): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
auth_not_configured: "账号认证配置不完整,请联系管理员。",
|
||||
callback_failed: "登录失败,请重新登录。",
|
||||
state_invalid: "登录状态已失效,请重新登录。"
|
||||
};
|
||||
|
||||
130
components/browser-auth.tsx
Normal file
130
components/browser-auth.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { AuthMode, AuthUser } from "@/lib/auth/session";
|
||||
import { fetchBrowserAuthState, safeBrowserLocationNext, type BrowserCurrentSession } from "@/lib/client/browser-auth";
|
||||
|
||||
type BrowserAuthStatus = "loading" | "ready" | "error";
|
||||
|
||||
type BrowserAuthState = {
|
||||
status: BrowserAuthStatus;
|
||||
user: AuthUser | null;
|
||||
authMode: AuthMode | null;
|
||||
authRequired: boolean;
|
||||
authConfigured: boolean;
|
||||
isAdmin: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
const BrowserAuthContext = createContext<BrowserAuthState | null>(null);
|
||||
|
||||
export function BrowserAuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [session, setSession] = useState<BrowserCurrentSession | null>(null);
|
||||
const [status, setStatus] = useState<BrowserAuthStatus>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadSession = useCallback(async (signal?: AbortSignal) => {
|
||||
try {
|
||||
const current = await fetchBrowserAuthState(signal);
|
||||
setSession(current);
|
||||
setStatus("ready");
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
||||
setStatus("error");
|
||||
setError(cause instanceof Error ? cause.message : "无法读取认证状态");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadSession(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [loadSession]);
|
||||
|
||||
const value = useMemo<BrowserAuthState>(() => {
|
||||
const user = session?.user || null;
|
||||
const authRequired = session?.authRequired ?? true;
|
||||
const isSuperAdmin = !authRequired || user?.role === "super_admin";
|
||||
const isAdmin = isSuperAdmin || user?.role === "organization_admin";
|
||||
return {
|
||||
status,
|
||||
user,
|
||||
authMode: session?.authMode || null,
|
||||
authRequired,
|
||||
authConfigured: session?.authConfigured ?? false,
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
error,
|
||||
refresh: () => loadSession()
|
||||
};
|
||||
}, [error, loadSession, session, status]);
|
||||
|
||||
return <BrowserAuthContext.Provider value={value}>{children}</BrowserAuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useBrowserAuth(): BrowserAuthState {
|
||||
const state = useContext(BrowserAuthContext);
|
||||
if (!state) throw new Error("useBrowserAuth must be used inside BrowserAuthProvider");
|
||||
return state;
|
||||
}
|
||||
|
||||
export function BrowserAuthGuard({
|
||||
children,
|
||||
role = "user"
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
role?: "user" | "admin" | "super";
|
||||
}) {
|
||||
const auth = useBrowserAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const needsLogin = auth.status === "ready" && auth.authRequired && !auth.user;
|
||||
|
||||
useEffect(() => {
|
||||
if (needsLogin) {
|
||||
const next = safeBrowserLocationNext(window.location, pathname || "/create");
|
||||
router.replace(`/auth/login?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
}, [needsLogin, pathname, router]);
|
||||
|
||||
if (auth.status === "loading" || needsLogin) {
|
||||
return <AuthStatePanel message="正在确认登录状态…" />;
|
||||
}
|
||||
if (auth.status === "error") {
|
||||
return <AuthStatePanel message={auth.error || "无法读取认证状态"} actionLabel="重试" onAction={() => void auth.refresh()} />;
|
||||
}
|
||||
if ((role === "admin" && !auth.isAdmin) || (role === "super" && !auth.isSuperAdmin)) {
|
||||
return <AuthStatePanel message="当前账号无权访问此页面。" />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function ClientRedirect({ to, forwardNext = false }: { to: string; forwardNext?: boolean }) {
|
||||
useEffect(() => {
|
||||
const next = forwardNext ? new URLSearchParams(window.location.search).get("next") : null;
|
||||
const destination = next ? `${to}?next=${encodeURIComponent(next)}` : to;
|
||||
window.location.replace(destination);
|
||||
}, [forwardNext, to]);
|
||||
return <AuthStatePanel message="正在跳转…" />;
|
||||
}
|
||||
|
||||
function AuthStatePanel({
|
||||
message,
|
||||
actionLabel,
|
||||
onAction
|
||||
}: {
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p className="muted">{message}</p>
|
||||
{actionLabel && onAction ? <button className="button" type="button" onClick={onAction}>{actionLabel}</button> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user