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

@@ -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 {