Add authentication features for admin access

- Introduce a new `auth` module with login, logout, and user verification endpoints for a single admin user.
- Update backend routes to require admin authentication for sensitive operations, enhancing security.
- Modify frontend components to include an authentication provider and gate, ensuring only authorized users can access the application.
- Implement a login page for admin access, improving user experience and security management.
- Update API request handling to redirect unauthorized users to the login page, ensuring proper access control.
This commit is contained in:
Xin Wang
2026-07-09 23:27:50 +08:00
parent d857401ef3
commit 590cb33cbd
20 changed files with 534 additions and 29 deletions

View File

@@ -2,7 +2,8 @@ import type { Metadata } from "next";
import { Geist_Mono, Inter, Cormorant_Garamond } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import { AppShell } from "@/components/layout/AppShell";
import { AuthGate } from "@/components/auth/AuthGate";
import { AuthProvider } from "@/components/auth/AuthProvider";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
@@ -50,7 +51,9 @@ export default function RootLayout({
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-full flex flex-col">
<AppShell>{children}</AppShell>
<AuthProvider>
<AuthGate>{children}</AuthGate>
</AuthProvider>
</body>
</html>
);

View File

@@ -0,0 +1,104 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { LockKeyhole, LogIn, Video } from "lucide-react";
import { useAuth } from "@/components/auth/AuthProvider";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
export default function LoginPage() {
const router = useRouter();
const { user, loading, login } = useAuth();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!loading && user) {
router.replace("/");
}
}, [loading, router, user]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setSubmitting(true);
try {
await login(username.trim(), password);
router.replace("/");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setSubmitting(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-10 text-foreground">
<Card className="w-full max-w-[420px] rounded-2xl border border-hairline bg-card shadow-lg">
<CardHeader className="gap-3">
<div
className="flex h-11 w-11 items-center justify-center rounded-2xl text-on-primary shadow-sm"
style={{
backgroundColor: "var(--primary)",
backgroundImage:
"radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--gradient-sky) 70%, transparent), transparent 60%), radial-gradient(circle at 80% 90%, color-mix(in srgb, var(--gradient-lavender) 65%, transparent), transparent 55%)",
}}
>
<Video size={22} style={{ color: "var(--primary-foreground)" }} />
</div>
<div>
<CardTitle className="text-xl">AI </CardTitle>
<CardDescription className="mt-1">
使
</CardDescription>
</div>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<label className="block space-y-2">
<span className="text-sm text-muted-foreground"></span>
<Input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="admin"
/>
</label>
<label className="block space-y-2">
<span className="text-sm text-muted-foreground"></span>
<Input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
/>
</label>
{error ? (
<div className="flex items-center gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<LockKeyhole size={15} />
{error}
</div>
) : null}
<Button className="w-full gap-2" size="lg" disabled={submitting}>
<LogIn size={17} />
{submitting ? "登录中..." : "登录"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { AppShell } from "@/components/layout/AppShell";
import { useAuth } from "./AuthProvider";
export function AuthGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { user, loading } = useAuth();
const isLoginPage = pathname === "/login";
useEffect(() => {
if (!loading && !user && !isLoginPage) {
router.replace("/login");
}
}, [isLoginPage, loading, router, user]);
if (isLoginPage) {
return <>{children}</>;
}
if (loading || !user) {
return (
<div className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
...
</div>
);
}
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,84 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { authApi, type AdminUser } from "@/lib/api";
type AuthContextValue = {
user: AdminUser | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AdminUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const currentUser = await authApi.me();
setUser(currentUser);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let active = true;
authApi
.me()
.then((currentUser) => {
if (active) setUser(currentUser);
})
.catch(() => {
if (active) setUser(null);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
const login = useCallback(async (username: string, password: string) => {
const currentUser = await authApi.login({ username, password });
setUser(currentUser);
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} finally {
setUser(null);
}
}, []);
const value = useMemo(
() => ({ user, loading, login, logout, refresh }),
[user, loading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import {
Bot,
Boxes,
@@ -11,9 +11,11 @@ import {
Database,
Wrench,
Home,
LogOut,
PlayCircle,
Video,
} from "lucide-react";
import { useAuth } from "@/components/auth/AuthProvider";
type SidebarProps = {
collapsed: boolean;
@@ -39,6 +41,8 @@ const monitorSubItems: NavItem[] = [
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const { user, logout } = useAuth();
// 精确匹配或前缀匹配(带 / 边界),让 /assistants/xxx 也高亮"创建助手"
const isActive = (href: string) =>
@@ -47,6 +51,11 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const componentActive = componentSubItems.some((item) => isActive(item.href));
const monitorActive = monitorSubItems.some((item) => isActive(item.href));
async function handleLogout() {
await logout();
router.replace("/login");
}
return (
<aside
className={[
@@ -193,10 +202,9 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
</nav>
<div className="shrink-0 space-y-2 border-t border-sidebar-border bg-sidebar p-3 shadow-[0_-12px_24px_rgba(0,0,0,0.12)]">
{/* 个人中心 */}
<Link
href="/profile"
title={collapsed ? "个人中心 · 管理员" : undefined}
title={collapsed ? `个人中心 · ${user?.displayName ?? "超级管理员"}` : undefined}
className={[
"group relative flex w-full items-center overflow-hidden rounded-2xl border py-2 text-left transition-[background-color,color,border-color,box-shadow,transform] duration-200 active:scale-[0.98]",
isActive("/profile")
@@ -229,11 +237,30 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
].join(" ")}
>
<span className="block truncate text-sm font-medium text-foreground">
{user?.displayName ?? "超级管理员"}
</span>
</span>
</Link>
<button
onClick={handleLogout}
title={collapsed ? "退出登录" : undefined}
className={[
"group flex h-10 w-full items-center overflow-hidden rounded-full border border-hairline-strong text-sm text-muted-foreground transition-[background-color,color,border-color,transform] duration-200 hover:bg-surface-strong hover:text-foreground active:scale-[0.98]",
collapsed ? "justify-center gap-0 px-0" : "justify-between gap-2 px-3.5",
].join(" ")}
>
<span
className={[
"min-w-0 truncate transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
collapsed ? "w-0 opacity-0 -translate-x-2" : "opacity-100 translate-x-0",
].join(" ")}
>
退
</span>
<LogOut size={18} className="shrink-0 transition-transform duration-200 group-hover:translate-x-0.5" />
</button>
{/* 收起 / 展开侧栏 */}
<button
onClick={onToggle}

View File

@@ -1,14 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const [dark, setDark] = useState(true);
useEffect(() => {
setDark(document.documentElement.classList.contains("dark"));
}, []);
const [dark, setDark] = useState(() =>
typeof document === "undefined"
? true
: document.documentElement.classList.contains("dark"),
);
function toggle() {
const next = !dark;

View File

@@ -1,4 +1,4 @@
import { Bell, HelpCircle, ChevronDown } from "lucide-react";
import { Bell, HelpCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "./ThemeToggle";
import {

View File

@@ -3,7 +3,11 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState(() =>
typeof window === "undefined"
? false
: window.innerWidth < MOBILE_BREAKPOINT,
)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
@@ -11,9 +15,8 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
return isMobile
}

View File

@@ -12,8 +12,16 @@ export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agen
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
credentials: "include",
...init,
});
if (
res.status === 401 &&
typeof window !== "undefined" &&
!path.startsWith("/api/auth/")
) {
window.location.href = "/login";
}
if (!res.ok) {
let detail = `请求失败 (${res.status})`;
try {
@@ -29,6 +37,25 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return (text ? JSON.parse(text) : undefined) as T;
}
export type AdminUser = {
username: string;
displayName: string;
role: "super_admin";
};
export const authApi = {
login: (body: { username: string; password: string }) =>
request<AdminUser>("/api/auth/login", {
method: "POST",
body: JSON.stringify(body),
}),
logout: () =>
request<{ ok: boolean }>("/api/auth/logout", {
method: "POST",
}),
me: () => request<AdminUser>("/api/auth/me"),
};
// ---------- 接口定义驱动的模型注册表 ----------
export type InterfaceField = {
key: string;