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

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

View File

@@ -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
View 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)

View File

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

View File

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

View File

@@ -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("")

View File

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

View File

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

View File

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

View File

@@ -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'
```

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;