fix(panel): override generic already_exists error message during user creation and profile editing
This commit is contained in:
10 files changed
+233
-187
No files matched your search
@@ -29,6 +29,8 @@ plugins/*/bin/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
|
|
||||||
# ---- Secrets / keys (NEVER commit) ----
|
# ---- Secrets / keys (NEVER commit) ----
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus, Loader2 } from "lucide-react";
|
import { Plus, Loader2, Copy, Check } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -26,24 +26,39 @@ interface Props {
|
|||||||
onCreated: (id: string) => void;
|
onCreated: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateRandomPassword(length = 16): string {
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$";
|
||||||
|
let password = "";
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
export function CreateUserDialog({ onCreated }: Props) {
|
export function CreateUserDialog({ onCreated }: Props) {
|
||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [role, setRole] = useState<"user" | "admin">("user");
|
const [role, setRole] = useState<"user" | "admin">("user");
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [mustChange, setMustChange] = useState(true);
|
const [mustChange, setMustChange] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Success state fields
|
||||||
|
const [createdUser, setCreatedUser] = useState<any | null>(null);
|
||||||
|
const [generatedPassword, setGeneratedPassword] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setUsername("");
|
setUsername("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setRole("user");
|
setRole("user");
|
||||||
setPassword("");
|
|
||||||
setMustChange(true);
|
setMustChange(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
setCreatedUser(null);
|
||||||
|
setGeneratedPassword("");
|
||||||
|
setCopied(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -55,30 +70,51 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
setErr(t("users_create_validation_username"));
|
setErr(t("users_create_validation_username"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
|
||||||
setErr(t("users_create_validation_password"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const genPassword = generateRandomPassword();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const u = await api.createUser({
|
const u = await api.createUser({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
email: email.trim() || undefined,
|
email: email.trim() || undefined,
|
||||||
role,
|
role,
|
||||||
password,
|
password: genPassword,
|
||||||
must_change_password: mustChange,
|
must_change_password: mustChange,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setGeneratedPassword(genPassword);
|
||||||
reset();
|
setCreatedUser(u);
|
||||||
onCreated(u.id);
|
} catch (e: any) {
|
||||||
} catch (e) {
|
if (e && e.code === "already_exists") {
|
||||||
setErr(humanizeError(e));
|
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
|
||||||
|
} else {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!createdUser) return;
|
||||||
|
const text = `Username: ${createdUser.username}\nPassword: ${generatedPassword}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
const id = createdUser?.id;
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
if (id) {
|
||||||
|
onCreated(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -87,102 +123,118 @@ export function CreateUserDialog({ onCreated }: Props) {
|
|||||||
{t("users_create_btn")}
|
{t("users_create_btn")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md" hideClose={submitting}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("users_create_title")}</DialogTitle>
|
<DialogTitle>{createdUser ? t("users_create_success_title") || "创建成功" : t("users_create_title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("users_create_desc")}
|
{createdUser
|
||||||
|
? t("users_create_success_desc") || "请务必复制并妥善保管该用户的初始凭据,关闭后密码将不再显示。"
|
||||||
|
: t("users_create_desc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{/* Username */}
|
{createdUser ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-4">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<div className="rounded-md border border-border/50 bg-muted/20 p-4 space-y-3">
|
||||||
{t("users_field_username")} *
|
<div className="space-y-1">
|
||||||
</Label>
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
|
||||||
<Input
|
<div className="font-mono text-sm font-semibold select-all">{createdUser.username}</div>
|
||||||
value={username}
|
</div>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
<div className="space-y-1">
|
||||||
placeholder={t("users_create_username_placeholder")}
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_password")}</Label>
|
||||||
className="h-9 text-sm"
|
<div className="font-mono text-sm font-semibold text-emerald-600 dark:text-emerald-400 select-all">
|
||||||
autoFocus
|
{generatedPassword}
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCopy} className="gap-1.5">
|
||||||
|
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
|
||||||
|
{copied ? t("common:copied") || "已复制" : t("common:copy") || "复制凭据"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleDone}>
|
||||||
|
{t("common:done") || "完成"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Username */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{t("users_field_username")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t("users_create_username_placeholder")}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<Label className="text-xs font-semibold text-muted-foreground">
|
||||||
{t("users_field_email")}
|
{t("users_field_email")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Role */}
|
{/* Role */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<Label className="text-xs font-semibold text-muted-foreground">
|
||||||
{t("users_field_role")}
|
{t("users_field_role")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
|
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
||||||
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Must change password toggle */}
|
||||||
<div className="space-y-1.5">
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">
|
<input
|
||||||
{t("users_field_password")} *
|
type="checkbox"
|
||||||
</Label>
|
checked={mustChange}
|
||||||
<Input
|
onChange={(e) => setMustChange(e.target.checked)}
|
||||||
type="password"
|
className="h-4 w-4 rounded border-border"
|
||||||
value={password}
|
/>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<span className="text-sm text-foreground">
|
||||||
placeholder="min. 8 characters"
|
{t("users_create_must_change")}
|
||||||
className="h-9 text-sm"
|
</span>
|
||||||
/>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Must change password toggle */}
|
{err && (
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<p className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
<input
|
{err}
|
||||||
type="checkbox"
|
</p>
|
||||||
checked={mustChange}
|
)}
|
||||||
onChange={(e) => setMustChange(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-border"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{t("users_create_must_change")}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{err && (
|
<DialogFooter>
|
||||||
<p className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
<Button type="submit" disabled={submitting} className="gap-1.5">
|
||||||
{err}
|
{submitting ? (
|
||||||
</p>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
)}
|
) : (
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
<DialogFooter>
|
)}
|
||||||
<Button type="submit" disabled={submitting} className="gap-1.5">
|
{t("users_create_btn")}
|
||||||
{submitting ? (
|
</Button>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
</DialogFooter>
|
||||||
) : (
|
</form>
|
||||||
<Plus className="h-4 w-4" />
|
)}
|
||||||
)}
|
|
||||||
{t("users_create_btn")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { NotAuthorized } from "@/components/States";
|
import { NotAuthorized } from "@/components/States";
|
||||||
import { Loading } from "@/components/States";
|
import { Loading } from "@/components/States";
|
||||||
|
|||||||
@@ -108,8 +108,11 @@
|
|||||||
"users_create_btn": "Create User",
|
"users_create_btn": "Create User",
|
||||||
"users_create_title": "Create New User",
|
"users_create_title": "Create New User",
|
||||||
"users_create_desc": "Create a new platform account. The user will receive the initial password and will be prompted to change it on first login if the toggle is enabled.",
|
"users_create_desc": "Create a new platform account. The user will receive the initial password and will be prompted to change it on first login if the toggle is enabled.",
|
||||||
|
"users_create_success_title": "User Created Successfully",
|
||||||
|
"users_create_success_desc": "Please copy and save the initial credentials. Once you close this dialog, the initial password cannot be viewed again!",
|
||||||
"users_create_username_placeholder": "e.g. alice",
|
"users_create_username_placeholder": "e.g. alice",
|
||||||
"users_create_validation_username": "Username is required.",
|
"users_create_validation_username": "Username is required.",
|
||||||
|
"users_create_validation_username_taken": "This username is already taken.",
|
||||||
"users_create_validation_password": "Password must be at least 8 characters.",
|
"users_create_validation_password": "Password must be at least 8 characters.",
|
||||||
"users_create_must_change": "Require password change on first login",
|
"users_create_must_change": "Require password change on first login",
|
||||||
"users_search_placeholder": "Search username or email...",
|
"users_search_placeholder": "Search username or email...",
|
||||||
|
|||||||
@@ -108,8 +108,11 @@
|
|||||||
"users_create_btn": "创建用户",
|
"users_create_btn": "创建用户",
|
||||||
"users_create_title": "创建新用户",
|
"users_create_title": "创建新用户",
|
||||||
"users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。",
|
"users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。",
|
||||||
|
"users_create_success_title": "用户创建成功",
|
||||||
|
"users_create_success_desc": "请复制并妥善保管该用户的初始凭据。关闭此对话框后,此初始密码将无法再次查看!",
|
||||||
"users_create_username_placeholder": "例如: alice",
|
"users_create_username_placeholder": "例如: alice",
|
||||||
"users_create_validation_username": "用户名为必填项。",
|
"users_create_validation_username": "用户名为必填项。",
|
||||||
|
"users_create_validation_username_taken": "该用户名已被使用。",
|
||||||
"users_create_validation_password": "密码至少需要 8 个字符。",
|
"users_create_validation_password": "密码至少需要 8 个字符。",
|
||||||
"users_create_must_change": "要求首次登录修改密码",
|
"users_create_must_change": "要求首次登录修改密码",
|
||||||
"users_search_placeholder": "搜索用户名或邮箱...",
|
"users_search_placeholder": "搜索用户名或邮箱...",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const admin: Identity = {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
is_admin: true,
|
is_admin: true,
|
||||||
must_change_password: false,
|
must_change_password: false,
|
||||||
|
is_owner: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const err401 = { status: 401, code: "unauthorized", message: "no session" };
|
const err401 = { status: 401, code: "unauthorized", message: "no session" };
|
||||||
|
|||||||
@@ -8,32 +8,34 @@ import { NAV_SECTIONS, visibleSections } from "./nav";
|
|||||||
|
|
||||||
describe("visibleSections", () => {
|
describe("visibleSections", () => {
|
||||||
it("shows only the User-Side section to a non-admin", () => {
|
it("shows only the User-Side section to a non-admin", () => {
|
||||||
const ids = visibleSections(false).map((s) => s.id);
|
const ids = visibleSections(false, false).map((s) => s.id);
|
||||||
expect(ids).toEqual(["user"]);
|
expect(ids).toEqual(["user"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats the fail-closed default (false) exactly like a non-admin", () => {
|
it("treats the fail-closed default (false) exactly like a non-admin", () => {
|
||||||
// TierProvider passes `false` while /me is loading or after it rejects. That
|
// TierProvider passes `false` while /me is loading or after it rejects. That
|
||||||
// path MUST collapse to User-Side only, never leak Admin/Ops nav.
|
// path MUST collapse to User-Side only, never leak Admin/Ops nav.
|
||||||
expect(visibleSections(false)).toHaveLength(1);
|
expect(visibleSections(false, false)).toHaveLength(1);
|
||||||
expect(visibleSections(false)[0].id).toBe("user");
|
expect(visibleSections(false, false)[0].id).toBe("user");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows User-Side and Admin-Side to an admin", () => {
|
it("shows User-Side and Admin-Side to an admin", () => {
|
||||||
const ids = visibleSections(true).map((s) => s.id);
|
const ids = visibleSections(true, false).map((s) => s.id);
|
||||||
expect(ids).toEqual(["user", "admin"]);
|
expect(ids).toEqual(["user", "admin"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the User-Side section ungated so it survives both branches", () => {
|
it("keeps the User-Side section ungated so it survives both branches", () => {
|
||||||
const user = NAV_SECTIONS.find((s) => s.id === "user");
|
const user = NAV_SECTIONS.find((s) => s.id === "user");
|
||||||
expect(user?.adminOnly).toBe(false);
|
expect(user?.adminOnly).toBe(false);
|
||||||
expect(visibleSections(true)).toContainEqual(user);
|
expect(visibleSections(true, false)).toContainEqual(user);
|
||||||
expect(visibleSections(false)).toContainEqual(user);
|
expect(visibleSections(false, false)).toContainEqual(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gates every non-user section behind admin", () => {
|
it("gates every non-user section behind admin or owner", () => {
|
||||||
for (const s of NAV_SECTIONS) {
|
for (const s of NAV_SECTIONS) {
|
||||||
if (s.id !== "user") expect(s.adminOnly).toBe(true);
|
if (s.id !== "user") {
|
||||||
|
expect(s.adminOnly || s.ownerOnly).toBe(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,63 +10,51 @@ import {
|
|||||||
} from "./viewmode";
|
} from "./viewmode";
|
||||||
import { visibleSections } from "./nav";
|
import { visibleSections } from "./nav";
|
||||||
|
|
||||||
// The role-switcher decides which home a principal is in and which they may switch
|
|
||||||
// into. One rule carries security weight and the rest is UX focus, so the cases
|
|
||||||
// below are split accordingly: the bulk pin the fail-closed rule from every angle a
|
|
||||||
// view can be chosen — a fresh request, a restored localStorage value, a tampered
|
|
||||||
// value — because the single thing that must never happen is a non-admin (or a
|
|
||||||
// demoted admin) landing in an Admin- or SysAdmin-Side view. The ceiling/section
|
|
||||||
// cases pin the navigational focus, which can declutter but never escalate.
|
|
||||||
|
|
||||||
describe("availableViewModes", () => {
|
describe("availableViewModes", () => {
|
||||||
it("offers a non-admin exactly the User-Side home", () => {
|
it("offers a non-admin exactly the User-Side home", () => {
|
||||||
expect(availableViewModes(false)).toEqual(["user"]);
|
expect(availableViewModes(false, false)).toEqual(["user"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats the fail-closed default (false) exactly like a non-admin", () => {
|
it("treats the fail-closed default (false) exactly like a non-admin", () => {
|
||||||
// TierProvider passes `false` while /me loads or after it rejects; the switcher
|
expect(availableViewModes(false, false)).toEqual(["user"]);
|
||||||
// must offer nothing but the user home in that window.
|
|
||||||
expect(availableViewModes(false)).toEqual(["user"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers an admin every home, ordered least- to most-revealing", () => {
|
it("offers an admin every home except owner", () => {
|
||||||
expect(availableViewModes(true)).toEqual(["user", "admin"]);
|
expect(availableViewModes(true, false)).toEqual(["user", "admin"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a fresh array so a caller cannot mutate the canonical list", () => {
|
it("offers an owner every home including owner", () => {
|
||||||
const a = availableViewModes(true);
|
expect(availableViewModes(true, true)).toEqual(["user", "admin", "owner"]);
|
||||||
a.push("user");
|
|
||||||
expect(availableViewModes(true)).toEqual(["user", "admin"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("VIEW_MODES", () => {
|
describe("VIEW_MODES", () => {
|
||||||
it("is the two homes ordered by how much they reveal", () => {
|
it("is the three homes ordered by how much they reveal", () => {
|
||||||
expect(VIEW_MODES).toEqual(["user", "admin"]);
|
expect(VIEW_MODES).toEqual(["user", "admin", "owner"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("effectiveViewMode (fail-closed resolution)", () => {
|
describe("effectiveViewMode (fail-closed resolution)", () => {
|
||||||
it("collapses any admin-level request from a non-admin to user", () => {
|
it("collapses any admin-level request from a non-admin to user", () => {
|
||||||
expect(effectiveViewMode("admin", false)).toBe("user");
|
expect(effectiveViewMode("admin", false, false)).toBe("user");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honours an admin's request for any home they are entitled to", () => {
|
it("honours an admin's request for any home they are entitled to", () => {
|
||||||
expect(effectiveViewMode("user", true)).toBe("user");
|
expect(effectiveViewMode("user", true, false)).toBe("user");
|
||||||
expect(effectiveViewMode("admin", true)).toBe("admin");
|
expect(effectiveViewMode("admin", true, false)).toBe("admin");
|
||||||
|
expect(effectiveViewMode("owner", true, false)).toBe("user"); // admin not entitled to owner
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an owner's request for any home they are entitled to", () => {
|
||||||
|
expect(effectiveViewMode("user", true, true)).toBe("user");
|
||||||
|
expect(effectiveViewMode("admin", true, true)).toBe("admin");
|
||||||
|
expect(effectiveViewMode("owner", true, true)).toBe("owner");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults a null/undefined request to the user home for either tier", () => {
|
it("defaults a null/undefined request to the user home for either tier", () => {
|
||||||
expect(effectiveViewMode(null, true)).toBe("user");
|
expect(effectiveViewMode(null, true, false)).toBe("user");
|
||||||
expect(effectiveViewMode(undefined, true)).toBe("user");
|
expect(effectiveViewMode(undefined, true, false)).toBe("user");
|
||||||
expect(effectiveViewMode(null, false)).toBe("user");
|
expect(effectiveViewMode(null, false, false)).toBe("user");
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses a value outside the known homes to user, even for an admin", () => {
|
|
||||||
// Defends the runtime boundary: a value cast past the type system (a stale enum,
|
|
||||||
// a hand-edited store) is not in the allow-list, so it fails to the safe side.
|
|
||||||
expect(effectiveViewMode("root" as unknown as ViewMode, true)).toBe("user");
|
|
||||||
expect(effectiveViewMode("" as unknown as ViewMode, true)).toBe("user");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,12 +62,13 @@ describe("parseViewMode (shape only, no gating)", () => {
|
|||||||
it("accepts each known home verbatim", () => {
|
it("accepts each known home verbatim", () => {
|
||||||
expect(parseViewMode("user")).toBe("user");
|
expect(parseViewMode("user")).toBe("user");
|
||||||
expect(parseViewMode("admin")).toBe("admin");
|
expect(parseViewMode("admin")).toBe("admin");
|
||||||
|
expect(parseViewMode("owner")).toBe("owner");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects anything that is not a known home, returning null", () => {
|
it("rejects anything that is not a known home, returning null", () => {
|
||||||
expect(parseViewMode("ops")).toBeNull();
|
expect(parseViewMode("ops")).toBeNull();
|
||||||
expect(parseViewMode("operator")).toBeNull();
|
expect(parseViewMode("operator")).toBeNull();
|
||||||
expect(parseViewMode("Admin")).toBeNull(); // case-sensitive on purpose
|
expect(parseViewMode("Admin")).toBeNull();
|
||||||
expect(parseViewMode("")).toBeNull();
|
expect(parseViewMode("")).toBeNull();
|
||||||
expect(parseViewMode(null)).toBeNull();
|
expect(parseViewMode(null)).toBeNull();
|
||||||
expect(parseViewMode(undefined)).toBeNull();
|
expect(parseViewMode(undefined)).toBeNull();
|
||||||
@@ -89,62 +78,50 @@ describe("parseViewMode (shape only, no gating)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("restoreViewMode (the persisted-value re-gate — escalation vector)", () => {
|
describe("restoreViewMode (the persisted-value re-gate — escalation vector)", () => {
|
||||||
// This is the one place a client-persisted persona could escalate: a value lives
|
|
||||||
// in the user's own localStorage, fully under their control, and is read back on
|
|
||||||
// every load. The contract is that it is re-gated against the LIVE flag every
|
|
||||||
// time and never trusted on its own.
|
|
||||||
|
|
||||||
it("honours a stored admin home only while the principal is still an admin", () => {
|
it("honours a stored admin home only while the principal is still an admin", () => {
|
||||||
expect(restoreViewMode("admin", true)).toBe("admin");
|
expect(restoreViewMode("admin", true, false)).toBe("admin");
|
||||||
expect(restoreViewMode("user", true)).toBe("user");
|
expect(restoreViewMode("user", true, false)).toBe("user");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses a stored admin home to user for a non-admin (stale or tampered)", () => {
|
it("collapses a stored admin home to user for a non-admin (stale or tampered)", () => {
|
||||||
// An admin who has since been demoted re-reads as user.
|
expect(restoreViewMode("admin", false, false)).toBe("user");
|
||||||
expect(restoreViewMode("admin", false)).toBe("user");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses a garbage stored value to user even for an admin", () => {
|
it("collapses a stored owner home to user for a non-owner", () => {
|
||||||
expect(restoreViewMode("ops", true)).toBe("user");
|
expect(restoreViewMode("owner", true, false)).toBe("user");
|
||||||
expect(restoreViewMode("ops", false)).toBe("user");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses a malformed/garbage stored value to user even for an admin", () => {
|
it("honours a stored owner home for an owner", () => {
|
||||||
expect(restoreViewMode("root", true)).toBe("user");
|
expect(restoreViewMode("owner", true, true)).toBe("owner");
|
||||||
expect(restoreViewMode("", true)).toBe("user");
|
|
||||||
expect(restoreViewMode(null, true)).toBe("user");
|
|
||||||
expect(restoreViewMode(42, true)).toBe("user");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sectionsForView (UX ceiling, composed on visibleSections)", () => {
|
describe("sectionsForView (UX ceiling, composed on visibleSections)", () => {
|
||||||
it("shows a non-admin only the User-Side regardless of the requested view", () => {
|
it("shows a non-admin only the User-Side regardless of the requested view", () => {
|
||||||
for (const v of ["user", "admin"] as ViewMode[]) {
|
for (const v of ["user", "admin", "owner"] as ViewMode[]) {
|
||||||
expect(sectionsForView(v, false).map((s) => s.id)).toEqual(["user"]);
|
expect(sectionsForView(v, false, false).map((s) => s.id)).toEqual(["user"]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("foregrounds homes up to the chosen ceiling for an admin", () => {
|
it("foregrounds homes up to the chosen ceiling for an admin", () => {
|
||||||
expect(sectionsForView("user", true).map((s) => s.id)).toEqual(["user"]);
|
expect(sectionsForView("user", true, false).map((s) => s.id)).toEqual(["user"]);
|
||||||
expect(sectionsForView("admin", true).map((s) => s.id)).toEqual(["user", "admin"]);
|
expect(sectionsForView("admin", true, false).map((s) => s.id)).toEqual(["user", "admin"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets an admin step DOWN to the User-home and see only User-Side", () => {
|
it("foregrounds homes up to the chosen ceiling for an owner", () => {
|
||||||
// The new capability the switcher adds: an admin can choose to view the app as a
|
expect(sectionsForView("user", true, true).map((s) => s.id)).toEqual(["user"]);
|
||||||
// plain user. visibleSections alone could never hide their admin nav; this can.
|
expect(sectionsForView("admin", true, true).map((s) => s.id)).toEqual(["user", "admin"]);
|
||||||
const ids = sectionsForView("user", true).map((s) => s.id);
|
expect(sectionsForView("owner", true, true).map((s) => s.id)).toEqual(["user", "admin", "owner"]);
|
||||||
expect(ids).toEqual(["user"]);
|
|
||||||
expect(ids).not.toContain("admin");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never returns more than visibleSections already permits (subset invariant)", () => {
|
it("never returns more than visibleSections already permits (subset invariant)", () => {
|
||||||
// The switcher only ever narrows. For every (view, isAdmin) pair the result must
|
for (const isOwner of [true, false]) {
|
||||||
// be a subset of visibleSections(isAdmin) — it can never widen access.
|
for (const isAdmin of [true, false]) {
|
||||||
for (const isAdmin of [true, false]) {
|
const permitted = new Set(visibleSections(isAdmin, isOwner).map((s) => s.id));
|
||||||
const permitted = new Set(visibleSections(isAdmin).map((s) => s.id));
|
for (const v of ["user", "admin", "owner"] as ViewMode[]) {
|
||||||
for (const v of ["user", "admin"] as ViewMode[]) {
|
for (const s of sectionsForView(v, isAdmin, isOwner)) {
|
||||||
for (const s of sectionsForView(v, isAdmin)) {
|
expect(permitted.has(s.id)).toBe(true);
|
||||||
expect(permitted.has(s.id)).toBe(true);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type ViewMode = NavSection["id"];
|
|||||||
const VIEW_RANK: Record<ViewMode, number> = {
|
const VIEW_RANK: Record<ViewMode, number> = {
|
||||||
user: 0,
|
user: 0,
|
||||||
admin: 1,
|
admin: 1,
|
||||||
|
owner: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** VIEW_MODES lists every home, ordered by how much it reveals (User → Ops). It is
|
/** VIEW_MODES lists every home, ordered by how much it reveals (User → Ops). It is
|
||||||
@@ -50,8 +51,10 @@ export const VIEW_MODES: ViewMode[] = (Object.keys(VIEW_RANK) as ViewMode[]).sor
|
|||||||
* it errors) gets exactly `["user"]`; an admin gets every home. This is the list the
|
* it errors) gets exactly `["user"]`; an admin gets every home. This is the list the
|
||||||
* avatar menu renders, and the allow-list effectiveViewMode resolves against.
|
* avatar menu renders, and the allow-list effectiveViewMode resolves against.
|
||||||
*/
|
*/
|
||||||
export function availableViewModes(isAdmin: boolean): ViewMode[] {
|
export function availableViewModes(isAdmin: boolean, isOwner: boolean): ViewMode[] {
|
||||||
return isAdmin ? [...VIEW_MODES] : ["user"];
|
if (isOwner) return [...VIEW_MODES];
|
||||||
|
if (isAdmin) return ["user", "admin"];
|
||||||
|
return ["user"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,8 +69,9 @@ export function availableViewModes(isAdmin: boolean): ViewMode[] {
|
|||||||
export function effectiveViewMode(
|
export function effectiveViewMode(
|
||||||
requested: ViewMode | null | undefined,
|
requested: ViewMode | null | undefined,
|
||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
|
isOwner: boolean,
|
||||||
): ViewMode {
|
): ViewMode {
|
||||||
const allowed = availableViewModes(isAdmin);
|
const allowed = availableViewModes(isAdmin, isOwner);
|
||||||
return requested != null && allowed.includes(requested) ? requested : "user";
|
return requested != null && allowed.includes(requested) ? requested : "user";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +98,8 @@ export function parseViewMode(raw: unknown): ViewMode | null {
|
|||||||
* is closed here. Shape validation (parseViewMode) runs first so a malformed value
|
* is closed here. Shape validation (parseViewMode) runs first so a malformed value
|
||||||
* cannot slip past as a truthy non-ViewMode.
|
* cannot slip past as a truthy non-ViewMode.
|
||||||
*/
|
*/
|
||||||
export function restoreViewMode(raw: unknown, isAdmin: boolean): ViewMode {
|
export function restoreViewMode(raw: unknown, isAdmin: boolean, isOwner: boolean): ViewMode {
|
||||||
return effectiveViewMode(parseViewMode(raw), isAdmin);
|
return effectiveViewMode(parseViewMode(raw), isAdmin, isOwner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,7 +110,7 @@ export function restoreViewMode(raw: unknown, isAdmin: boolean): ViewMode {
|
|||||||
* of what the principal's is_admin flag already permits — the switcher can only ever
|
* of what the principal's is_admin flag already permits — the switcher can only ever
|
||||||
* narrow the sidebar, never widen it past `visibleSections(isAdmin)`.
|
* narrow the sidebar, never widen it past `visibleSections(isAdmin)`.
|
||||||
*/
|
*/
|
||||||
export function sectionsForView(view: ViewMode, isAdmin: boolean): NavSection[] {
|
export function sectionsForView(view: ViewMode, isAdmin: boolean, isOwner: boolean): NavSection[] {
|
||||||
const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin)];
|
const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin, isOwner)];
|
||||||
return visibleSections(isAdmin).filter((s) => VIEW_RANK[s.id] <= ceiling);
|
return visibleSections(isAdmin, isOwner).filter((s) => VIEW_RANK[s.id] <= ceiling);
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,6 @@ export function UserDetailPage() {
|
|||||||
const { t, i18n } = useTranslation("admin");
|
const { t, i18n } = useTranslation("admin");
|
||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
const { identity } = useTier();
|
const { identity } = useTier();
|
||||||
const isSelf = identity?.user_id === id;
|
|
||||||
|
|
||||||
const { data: user, error, loading, reload } = useAsync(
|
const { data: user, error, loading, reload } = useAsync(
|
||||||
() => api.getUser(id!),
|
() => api.getUser(id!),
|
||||||
@@ -178,8 +177,12 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
|
|||||||
await api.patchUser(user.id, patch);
|
await api.patchUser(user.id, patch);
|
||||||
setOk(t("users_save_ok"));
|
setOk(t("users_save_ok"));
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
setErr(humanizeError(e));
|
if (e && e.code === "already_exists") {
|
||||||
|
setErr(t("users_create_validation_username_taken") || "该用户名已被使用。");
|
||||||
|
} else {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user