feat: add local access control and managed point clouds
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
48
client/src/pages/ChangePassword.tsx
Normal file
48
client/src/pages/ChangePassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
client/src/pages/Login.tsx
Normal file
53
client/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
client/src/pages/Users.tsx
Normal file
68
client/src/pages/Users.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user