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:
@@ -22,6 +22,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from routes import (
|
||||
assistants,
|
||||
auth,
|
||||
health,
|
||||
knowledge_bases,
|
||||
model_registry,
|
||||
@@ -48,6 +49,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(assistants.router)
|
||||
app.include_router(knowledge_bases.router)
|
||||
app.include_router(model_registry.router)
|
||||
|
||||
@@ -6,12 +6,17 @@ from db.models import Assistant, AssistantModelBinding, ModelResource
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from schemas import AssistantOut, AssistantUpsert
|
||||
from services.auth import require_admin
|
||||
from services.masking import mask, resolve_incoming_key
|
||||
from services.node_specs import validate_graph
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
|
||||
router = APIRouter(
|
||||
prefix="/api/assistants",
|
||||
tags=["assistants"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent")
|
||||
|
||||
|
||||
|
||||
56
backend/routes/auth.py
Normal file
56
backend/routes/auth.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Single-admin login endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
from services.auth import (
|
||||
AdminUser,
|
||||
authenticate_admin,
|
||||
clear_auth_cookie,
|
||||
create_admin_token,
|
||||
require_admin,
|
||||
set_auth_cookie,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
username: str
|
||||
displayName: str
|
||||
role: str
|
||||
|
||||
|
||||
def _to_out(user: AdminUser) -> AdminUserOut:
|
||||
return AdminUserOut(
|
||||
username=user.username,
|
||||
displayName=user.display_name,
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AdminUserOut)
|
||||
async def login(body: LoginIn, response: Response):
|
||||
user = authenticate_admin(body.username, body.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
set_auth_cookie(response, create_admin_token())
|
||||
return _to_out(user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
clear_auth_cookie(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminUserOut)
|
||||
async def me(user: AdminUser = Depends(require_admin)):
|
||||
return _to_out(user)
|
||||
@@ -10,11 +10,16 @@ from db.models import KnowledgeBase, ModelResource
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert
|
||||
from services.auth import require_admin
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/knowledge-bases", tags=["knowledge-bases"])
|
||||
router = APIRouter(
|
||||
prefix="/api/knowledge-bases",
|
||||
tags=["knowledge-bases"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
async def _validate_embedding_resource(
|
||||
|
||||
@@ -16,13 +16,18 @@ from schemas import (
|
||||
ModelResourceTestResult,
|
||||
ModelResourceUpsert,
|
||||
)
|
||||
from services.auth import require_admin
|
||||
from services.interface_catalog import validate_fields
|
||||
from services.masking import mask_secrets, merge_secrets
|
||||
from services.model_resource_tester import test_model_resource
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["model-registry"])
|
||||
router = APIRouter(
|
||||
prefix="/api",
|
||||
tags=["model-registry"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _definition_dict(row: InterfaceDefinition) -> dict:
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
规格是只读的、与代码同生命周期(改了要重启后端 + 前端刷新),所以无需鉴权与缓存层。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from services.auth import require_admin
|
||||
from services.node_specs import node_types_response
|
||||
|
||||
router = APIRouter(prefix="/api/node-types", tags=["workflow"])
|
||||
router = APIRouter(
|
||||
prefix="/api/node-types",
|
||||
tags=["workflow"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
import asyncio
|
||||
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from fastapi import APIRouter, Depends, WebSocket
|
||||
from loguru import logger
|
||||
from models import AssistantConfig, SignalingOffer
|
||||
from services.auth import require_admin, require_admin_websocket
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
||||
|
||||
@@ -24,7 +25,7 @@ from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
|
||||
router = APIRouter(tags=["voice"])
|
||||
|
||||
|
||||
@router.get("/api/webrtc/ice-servers")
|
||||
@router.get("/api/webrtc/ice-servers", dependencies=[Depends(require_admin)])
|
||||
async def ice_servers():
|
||||
"""Browser fetches STUN/TURN config (with ephemeral TURN creds when configured)."""
|
||||
return {"iceServers": client_ice_servers()}
|
||||
@@ -32,6 +33,8 @@ async def ice_servers():
|
||||
|
||||
@router.websocket("/ws/voice")
|
||||
async def voice_signaling(websocket: WebSocket):
|
||||
if not await require_admin_websocket(websocket):
|
||||
return
|
||||
await websocket.accept()
|
||||
peers: dict = {}
|
||||
try:
|
||||
|
||||
@@ -15,6 +15,7 @@ from db.session import SessionLocal
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from services.auth import require_admin_websocket
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
@@ -38,6 +39,8 @@ async def voice_stream(websocket: WebSocket):
|
||||
from services.pipecat.pipeline import run_pipeline
|
||||
from services.pipecat.transports import build_ws_transport
|
||||
|
||||
if not await require_admin_websocket(websocket):
|
||||
return
|
||||
await websocket.accept()
|
||||
try:
|
||||
cfg = await _resolve_start_config(await websocket.receive_text())
|
||||
|
||||
125
backend/services/auth.py
Normal file
125
backend/services/auth.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Single-admin authentication helpers.
|
||||
|
||||
The project currently needs one deployment-level super admin, configured by
|
||||
environment variables. Tokens are signed with HMAC and stored in an HttpOnly
|
||||
cookie so the frontend does not handle bearer tokens directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import settings
|
||||
from fastapi import HTTPException, Request, Response, WebSocket, status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminUser:
|
||||
username: str
|
||||
display_name: str = "超级管理员"
|
||||
role: str = "super_admin"
|
||||
|
||||
|
||||
def _b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(data: str) -> bytes:
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(f"{data}{padding}".encode("ascii"))
|
||||
|
||||
|
||||
def _sign(payload: str) -> str:
|
||||
digest = hmac.new(
|
||||
settings.AUTH_SECRET_KEY.encode("utf-8"),
|
||||
payload.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return _b64encode(digest)
|
||||
|
||||
|
||||
def _admin_user() -> AdminUser:
|
||||
return AdminUser(username=settings.ADMIN_USERNAME)
|
||||
|
||||
|
||||
def create_admin_token() -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": settings.ADMIN_USERNAME,
|
||||
"role": "super_admin",
|
||||
"iat": now,
|
||||
"exp": now + settings.AUTH_TOKEN_EXPIRE_MINUTES * 60,
|
||||
}
|
||||
encoded_payload = _b64encode(
|
||||
json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
)
|
||||
return f"{encoded_payload}.{_sign(encoded_payload)}"
|
||||
|
||||
|
||||
def verify_admin_token(token: str | None) -> AdminUser | None:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
encoded_payload, signature = token.rsplit(".", 1)
|
||||
if not hmac.compare_digest(_sign(encoded_payload), signature):
|
||||
return None
|
||||
try:
|
||||
payload: dict[str, Any] = json.loads(_b64decode(encoded_payload))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if payload.get("sub") != settings.ADMIN_USERNAME:
|
||||
return None
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
return None
|
||||
return _admin_user()
|
||||
|
||||
|
||||
def authenticate_admin(username: str, password: str) -> AdminUser | None:
|
||||
username_ok = hmac.compare_digest(username, settings.ADMIN_USERNAME)
|
||||
password_ok = hmac.compare_digest(password, settings.ADMIN_PASSWORD)
|
||||
return _admin_user() if username_ok and password_ok else None
|
||||
|
||||
|
||||
def set_auth_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
settings.AUTH_COOKIE_NAME,
|
||||
token,
|
||||
max_age=settings.AUTH_TOKEN_EXPIRE_MINUTES * 60,
|
||||
httponly=True,
|
||||
secure=settings.AUTH_COOKIE_SECURE,
|
||||
samesite=settings.AUTH_COOKIE_SAMESITE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
settings.AUTH_COOKIE_NAME,
|
||||
httponly=True,
|
||||
secure=settings.AUTH_COOKIE_SECURE,
|
||||
samesite=settings.AUTH_COOKIE_SAMESITE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(request: Request) -> AdminUser:
|
||||
user = verify_admin_token(request.cookies.get(settings.AUTH_COOKIE_NAME))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="未登录或登录已过期",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin_websocket(websocket: WebSocket) -> AdminUser | None:
|
||||
user = verify_admin_token(websocket.cookies.get(settings.AUTH_COOKIE_NAME))
|
||||
if not user:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return None
|
||||
return user
|
||||
@@ -29,6 +29,15 @@ CORS_ORIGINS = _split(
|
||||
os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
|
||||
)
|
||||
|
||||
# ---- Admin auth ----
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "znjj189981$")
|
||||
AUTH_SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "dev-secret-change-me")
|
||||
AUTH_COOKIE_NAME = os.getenv("AUTH_COOKIE_NAME", "ai_video_admin_token")
|
||||
AUTH_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "1440"))
|
||||
AUTH_COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true"
|
||||
AUTH_COOKIE_SAMESITE = os.getenv("AUTH_COOKIE_SAMESITE", "lax")
|
||||
|
||||
# ---- WebRTC TURN ----
|
||||
# TURN_URLS example:
|
||||
# turn:182.92.86.220:3478?transport=udp,turn:182.92.86.220:3478?transport=tcp
|
||||
|
||||
@@ -94,12 +94,15 @@ uv run --with-requirements requirements.txt \
|
||||
|
||||
### 4. 单独启动前端
|
||||
|
||||
前端也监听 `0.0.0.0`。`NEXT_PUBLIC_API_BASE_URL` 要写浏览器最终访问 nginx 的
|
||||
https 地址,不要写 `:8000`:
|
||||
前端也监听 `0.0.0.0`。`NEXT_PUBLIC_API_BASE_URL` 只有一个规则:
|
||||
**写浏览器地址栏里访问 nginx 的同源地址**。如果地址栏带端口,这里也必须带端口;
|
||||
如果地址栏不带端口,这里也不带端口。不要写后端直连端口 `:8000`。
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# nginx 使用默认 443 时:
|
||||
NEXT_PUBLIC_API_BASE_URL=https://<远程机器IP或域名> \
|
||||
npm run dev -- --hostname 0.0.0.0
|
||||
```
|
||||
@@ -146,7 +149,7 @@ docker run --rm --name ai-video-nginx \
|
||||
访问:
|
||||
|
||||
```text
|
||||
https://<远程机器IP或域名>
|
||||
https://<远程机器IP或域名>:18189
|
||||
```
|
||||
|
||||
### 6. 远程语音 / WebRTC 注意事项
|
||||
@@ -174,11 +177,13 @@ TURN_SECRET=<同上>
|
||||
|
||||
## 前端怎么连后端
|
||||
|
||||
前端读环境变量 `NEXT_PUBLIC_API_BASE_URL`(compose 里已设)。走 nginx 后,
|
||||
让它指向**同源**即可,ws 地址由它推导:
|
||||
前端读环境变量 `NEXT_PUBLIC_API_BASE_URL`。走 nginx 后,让它等于浏览器访问
|
||||
页面的 **origin** 即可。origin 包含协议、host 和可选端口:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_API_BASE_URL = https://<访问用的host> # 同源,不再写 :8000
|
||||
默认 443: NEXT_PUBLIC_API_BASE_URL=https://<访问用的host>
|
||||
自定义端口: NEXT_PUBLIC_API_BASE_URL=https://<访问用的host>:18189
|
||||
|
||||
wsUrl = NEXT_PUBLIC_API_BASE_URL.replace(/^http/, 'ws') + '/ws/voice'
|
||||
```
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
104
frontend/src/app/login/page.tsx
Normal file
104
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/auth/AuthGate.tsx
Normal file
34
frontend/src/components/auth/AuthGate.tsx
Normal 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>;
|
||||
}
|
||||
84
frontend/src/components/auth/AuthProvider.tsx
Normal file
84
frontend/src/components/auth/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user