feat: add local access control and managed point clouds

This commit is contained in:
2026-07-22 11:05:48 +08:00
parent 683e4d6c07
commit 8555aa2f2e
46 changed files with 1982 additions and 884 deletions

View File

@@ -1,16 +1,33 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import { Redirect, Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Login from "./pages/Login";
import ChangePassword from "./pages/ChangePassword";
import Users from "./pages/Users";
import { useAuth } from "./_core/hooks/useAuth";
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
function RequireAuth({ children, superAdmin = false }: { children: React.ReactNode; superAdmin?: boolean }) {
const { user, loading } = useAuth();
if (loading) return <DashboardLayoutSkeleton />;
if (!user) return <Redirect to="/login" />;
if (user.mustChangePassword === 1) return <Redirect to="/change-password" />;
if (superAdmin && user.role !== "super_admin") return <Redirect to="/" />;
return <>{children}</>;
}
function Router() {
// make sure to consider if you need authentication for certain routes
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path="/login" component={Login} />
<Route path="/change-password" component={ChangePassword} />
<Route path="/users">{() => <RequireAuth superAdmin><Users /></RequireAuth>}</Route>
<Route path="/">{() => <RequireAuth><Home /></RequireAuth>}</Route>
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) {
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: () => {
utils.auth.me.setData(undefined, null);
utils.auth.me.setData(undefined, undefined);
},
});
@@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) {
}
throw error;
} finally {
utils.auth.me.setData(undefined, null);
utils.auth.me.setData(undefined, undefined);
await utils.auth.me.invalidate();
}
}, [logoutMutation, utils]);
const state = useMemo(() => {
localStorage.setItem(
"manus-runtime-user-info",
JSON.stringify(meQuery.data)
);
return {
user: meQuery.data ?? null,
loading: meQuery.isLoading || logoutMutation.isPending,
error: meQuery.error ?? logoutMutation.error ?? null,
isAuthenticated: Boolean(meQuery.data),
canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin",
isSuperAdmin: meQuery.data?.role === "super_admin",
};
}, [
meQuery.data,
@@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) {
if (typeof window === "undefined") return;
if (window.location.pathname === redirectPath) return;
window.location.href = redirectPath
window.location.href = redirectPath;
}, [
redirectOnUnauthenticated,
redirectPath,

View File

@@ -1,23 +1,2 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
const appId = import.meta.env.VITE_APP_ID;
// 本地认证模式:OAuth 未配置时回到首页(本地模式会自动登录)
if (!oauthPortalUrl || oauthPortalUrl === 'disabled') {
return '/';
}
const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri);
const url = new URL(`${oauthPortalUrl}/app-auth`);
url.searchParams.set("appId", appId || '');
url.searchParams.set("redirectUri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("type", "signIn");
return url.toString();
};
export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
export const getLoginUrl = () => "/login";

View File

@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
import { createRoot } from "react-dom/client";
import superjson from "superjson";
import { toast } from "sonner";
import App from "./App";
import { getLoginUrl } from "./const";
import "./index.css";
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
@@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
if (!isUnauthorized) return;
window.location.href = getLoginUrl();
if (window.location.pathname !== "/login") window.location.href = "/login";
};
queryClient.getQueryCache().subscribe(event => {
@@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => {
if (event.type === "updated" && event.action.type === "error") {
const error = event.mutation.state.error;
redirectToLoginIfUnauthorized(error);
if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") {
toast.error(error.message || "无权执行此操作");
}
console.error("[API Mutation Error]", error);
}
});

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import { Redirect, useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function ChangePassword() {
const { user, loading, logout } = useAuth();
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const change = trpc.auth.changePassword.useMutation({
onSuccess: async () => {
await utils.auth.me.invalidate();
setLocation("/");
},
});
if (!loading && !user) return <Redirect to="/login" />;
const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form className="w-full max-w-sm industrial-panel p-8 space-y-5" onSubmit={event => {
event.preventDefault();
if (!mismatch) change.mutate({ currentPassword, newPassword });
}}>
<div>
<h1 className="text-xl font-semibold">修改密码</h1>
<p className="text-sm text-muted-foreground mt-1">
{user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"}
</p>
</div>
<div className="space-y-2"><Label htmlFor="current">当前密码</Label><Input id="current" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="next">新密码(至少 8 位)</Label><Input id="next" type="password" autoComplete="new-password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="confirm">确认新密码</Label><Input id="confirm" type="password" autoComplete="new-password" minLength={8} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required /></div>
{mismatch && <p className="text-sm text-destructive">两次输入的新密码不一致</p>}
{change.error && <p className="text-sm text-destructive" role="alert">{change.error.message}</p>}
<Button className="w-full" type="submit" disabled={change.isPending || mismatch || newPassword.length < 8}>{change.isPending ? "正在保存…" : "保存新密码"}</Button>
<Button className="w-full" type="button" variant="ghost" onClick={() => logout().then(() => setLocation("/login"))}>退出登录</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { Redirect, useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Login() {
const { user, loading } = useAuth();
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const login = trpc.auth.login.useMutation({
onSuccess: async (signedInUser) => {
utils.auth.me.setData(undefined, signedInUser);
await utils.auth.me.invalidate();
setLocation(signedInUser.mustChangePassword === 1 ? "/change-password" : "/");
},
});
if (!loading && user) return <Redirect to={user.mustChangePassword === 1 ? "/change-password" : "/"} />;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form
className="w-full max-w-sm industrial-panel p-8 space-y-5"
onSubmit={(event) => {
event.preventDefault();
login.mutate({ username, password });
}}
>
<div>
<h1 className="text-xl font-semibold">宏量点云处理系统</h1>
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
</div>
<div className="space-y-2">
<Label htmlFor="username">用户名</Label>
<Input id="username" autoComplete="username" autoFocus value={username} onChange={e => setUsername(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input id="password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{login.error && <p className="text-sm text-destructive" role="alert">{login.error.message}</p>}
<Button className="w-full" type="submit" disabled={login.isPending || !username || !password}>
{login.isPending ? "正在登录…" : "登录"}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { ArrowLeft, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const roleNames = { super_admin: "超级管理员", admin: "管理员", user: "普通用户" } as const;
type Role = keyof typeof roleNames;
export default function Users() {
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [draftSearch, setDraftSearch] = useState("");
const [form, setForm] = useState({ username: "", temporaryPassword: "", name: "", email: "", role: "user" as Role });
const list = trpc.users.list.useQuery(search ? { search } : {});
const refresh = () => utils.users.list.invalidate();
const onError = (error: { message: string }) => toast.error(error.message);
const create = trpc.users.create.useMutation({ onSuccess: () => { toast.success("用户已创建"); setForm({ username: "", temporaryPassword: "", name: "", email: "", role: "user" }); void refresh(); }, onError });
const update = trpc.users.update.useMutation({ onSuccess: () => { toast.success("用户信息已更新"); void refresh(); }, onError });
const setActive = trpc.users.setActive.useMutation({ onSuccess: () => { toast.success("账号状态已更新"); void refresh(); }, onError });
const resetPassword = trpc.users.resetPassword.useMutation({ onSuccess: () => toast.success("临时密码已重置,旧会话已失效"), onError });
const remove = trpc.users.delete.useMutation({ onSuccess: () => { toast.success("用户已永久删除"); void refresh(); }, onError });
return (
<div className="min-h-screen bg-background p-6 space-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => setLocation("/")}><ArrowLeft className="w-4 h-4" /></Button><div><h1 className="text-xl font-semibold">用户管理</h1><p className="text-sm text-muted-foreground">创建账号、分配角色并管理登录状态</p></div></div>
</header>
<section className="industrial-panel p-5">
<h2 className="font-medium mb-4">新建用户</h2>
<form className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end" onSubmit={event => { event.preventDefault(); create.mutate(form); }}>
<div className="space-y-1"><Label>用户名</Label><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /></div>
<div className="space-y-1"><Label>姓名</Label><Input value={form.name} onChange={e => setForm(v => ({ ...v, name: e.target.value }))} required /></div>
<div className="space-y-1"><Label>邮箱(可选)</Label><Input type="email" value={form.email} onChange={e => setForm(v => ({ ...v, email: e.target.value }))} /></div>
<div className="space-y-1"><Label>临时密码</Label><Input type="password" minLength={8} value={form.temporaryPassword} onChange={e => setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /></div>
<div className="flex gap-2"><Select value={form.role} onValueChange={(role: Role) => setForm(v => ({ ...v, role }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select><Button type="submit" disabled={create.isPending}>创建</Button></div>
</form>
</section>
<section className="industrial-panel p-5 space-y-4">
<form className="flex gap-2 max-w-md" onSubmit={event => { event.preventDefault(); setSearch(draftSearch.trim()); }}><Input placeholder="搜索用户名、姓名或邮箱" value={draftSearch} onChange={e => setDraftSearch(e.target.value)} /><Button type="submit" variant="secondary"><Search className="w-4 h-4 mr-1" />搜索</Button></form>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-left text-muted-foreground"><th className="p-2">用户</th><th className="p-2">角色</th><th className="p-2">状态</th><th className="p-2">首次改密</th><th className="p-2">最后登录</th><th className="p-2 text-right">操作</th></tr></thead>
<tbody>{list.data?.map(user => (
<tr key={user.id} className="border-b border-border/60">
<td className="p-2"><div className="font-medium">{user.name || "未命名"}</div><div className="text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></td>
<td className="p-2"><Select value={user.role} onValueChange={(role: Role) => update.mutate({ id: user.id, role })}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select></td>
<td className="p-2"><button className={user.isActive === 1 ? "text-green-400" : "text-muted-foreground"} onClick={() => setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"}</button></td>
<td className="p-2">{user.mustChangePassword === 1 ? "是" : "否"}</td>
<td className="p-2 text-xs text-muted-foreground">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"}</td>
<td className="p-2"><div className="flex justify-end gap-1"><Button size="sm" variant="ghost" onClick={() => { const username = window.prompt("用户名", user.username); if (username == null) return; const name = window.prompt("姓名", user.name || ""); if (name == null) return; const email = window.prompt("邮箱(可留空)", user.email || ""); if (email == null) return; update.mutate({ id: user.id, username, name, email }); }}>编辑</Button><Button size="sm" variant="ghost" onClick={() => { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}><Trash2 className="w-4 h-4" /></Button></div></td>
</tr>
))}</tbody>
</table>
{list.isLoading && <p className="text-sm text-muted-foreground p-4">正在加载…</p>}
{list.error && <p className="text-sm text-destructive p-4">{list.error.message}</p>}
</div>
</section>
</div>
);
}