From 67b4e1904e232d045b112e81d3cbb67136147511 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sat, 4 Jul 2026 09:33:41 +0800 Subject: [PATCH] fix(panel): override generic already_exists error message during user creation and profile editing --- .gitignore | 2 + panel/src/components/CreateUserDialog.tsx | 252 +++++++++++++--------- panel/src/components/RequireOwner.tsx | 1 - panel/src/i18n/resources/en-US/admin.json | 3 + panel/src/i18n/resources/zh-CN/admin.json | 3 + panel/src/lib/auth.test.ts | 1 + panel/src/lib/nav.test.ts | 18 +- panel/src/lib/viewmode.test.ts | 113 ++++------ panel/src/lib/viewmode.ts | 20 +- panel/src/pages/admin/UserDetailPage.tsx | 9 +- 10 files changed, 234 insertions(+), 188 deletions(-) diff --git a/.gitignore b/.gitignore index 28c89df..a534ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ plugins/*/bin/ .vscode/ .idea/ *.iml +.DS_Store +**/.DS_Store # ---- Secrets / keys (NEVER commit) ---- *.pem diff --git a/panel/src/components/CreateUserDialog.tsx b/panel/src/components/CreateUserDialog.tsx index 83417a2..cd99b0d 100644 --- a/panel/src/components/CreateUserDialog.tsx +++ b/panel/src/components/CreateUserDialog.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Plus, Loader2 } from "lucide-react"; +import { Plus, Loader2, Copy, Check } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Dialog, @@ -26,24 +26,39 @@ interface Props { 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) { const { t } = useTranslation("admin"); const [open, setOpen] = useState(false); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [role, setRole] = useState<"user" | "admin">("user"); - const [password, setPassword] = useState(""); const [mustChange, setMustChange] = useState(true); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); + // Success state fields + const [createdUser, setCreatedUser] = useState(null); + const [generatedPassword, setGeneratedPassword] = useState(""); + const [copied, setCopied] = useState(false); + function reset() { setUsername(""); setEmail(""); setRole("user"); - setPassword(""); setMustChange(true); setErr(null); + setCreatedUser(null); + setGeneratedPassword(""); + setCopied(false); } async function handleSubmit(e: React.FormEvent) { @@ -55,30 +70,51 @@ export function CreateUserDialog({ onCreated }: Props) { setErr(t("users_create_validation_username")); return; } - if (password.length < 8) { - setErr(t("users_create_validation_password")); - return; - } + const genPassword = generateRandomPassword(); setSubmitting(true); try { const u = await api.createUser({ username: username.trim(), email: email.trim() || undefined, role, - password, + password: genPassword, must_change_password: mustChange, }); - setOpen(false); - reset(); - onCreated(u.id); - } catch (e) { - setErr(humanizeError(e)); + setGeneratedPassword(genPassword); + setCreatedUser(u); + } catch (e: any) { + if (e && e.code === "already_exists") { + setErr(t("users_create_validation_username_taken") || "该用户名已被使用。"); + } else { + setErr(humanizeError(e)); + } } finally { 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 ( { setOpen(v); if (!v) reset(); }}> @@ -87,103 +123,119 @@ export function CreateUserDialog({ onCreated }: Props) { {t("users_create_btn")} - + - {t("users_create_title")} + {createdUser ? t("users_create_success_title") || "创建成功" : t("users_create_title")} - {t("users_create_desc")} + {createdUser + ? t("users_create_success_desc") || "请务必复制并妥善保管该用户的初始凭据,关闭后密码将不再显示。" + : t("users_create_desc")} -
- {/* Username */} -
- - setUsername(e.target.value)} - placeholder={t("users_create_username_placeholder")} - className="h-9 text-sm" - autoFocus - /> + + {createdUser ? ( +
+
+
+ +
{createdUser.username}
+
+
+ +
+ {generatedPassword} +
+
+
+ + + + +
+ ) : ( + + {/* Username */} +
+ + setUsername(e.target.value)} + placeholder={t("users_create_username_placeholder")} + className="h-9 text-sm" + autoFocus + /> +
- {/* Email */} -
- - setEmail(e.target.value)} - placeholder="user@example.com" - className="h-9 text-sm" - /> -
+ {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + className="h-9 text-sm" + /> +
- {/* Role */} -
- - -
+ {/* Role */} +
+ + +
- {/* Password */} -
- - setPassword(e.target.value)} - placeholder="min. 8 characters" - className="h-9 text-sm" - /> -
+ {/* Must change password toggle */} + - {/* Must change password toggle */} - + {err && ( +

+ {err} +

+ )} - {err && ( -

- {err} -

- )} - - - - - + + + + + )}
); -} +} \ No newline at end of file diff --git a/panel/src/components/RequireOwner.tsx b/panel/src/components/RequireOwner.tsx index f68fcc3..8b79f23 100644 --- a/panel/src/components/RequireOwner.tsx +++ b/panel/src/components/RequireOwner.tsx @@ -1,5 +1,4 @@ import { Outlet } from "react-router-dom"; -import { useTranslation } from "react-i18next"; import { useTier } from "@/lib/tier"; import { NotAuthorized } from "@/components/States"; import { Loading } from "@/components/States"; diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 2dafc96..445cd72 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -108,8 +108,11 @@ "users_create_btn": "Create 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_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_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_must_change": "Require password change on first login", "users_search_placeholder": "Search username or email...", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 5b9ea52..3f2a2b2 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -108,8 +108,11 @@ "users_create_btn": "创建用户", "users_create_title": "创建新用户", "users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。", + "users_create_success_title": "用户创建成功", + "users_create_success_desc": "请复制并妥善保管该用户的初始凭据。关闭此对话框后,此初始密码将无法再次查看!", "users_create_username_placeholder": "例如: alice", "users_create_validation_username": "用户名为必填项。", + "users_create_validation_username_taken": "该用户名已被使用。", "users_create_validation_password": "密码至少需要 8 个字符。", "users_create_must_change": "要求首次登录修改密码", "users_search_placeholder": "搜索用户名或邮箱...", diff --git a/panel/src/lib/auth.test.ts b/panel/src/lib/auth.test.ts index ed1e516..751e29b 100644 --- a/panel/src/lib/auth.test.ts +++ b/panel/src/lib/auth.test.ts @@ -15,6 +15,7 @@ const admin: Identity = { role: "admin", is_admin: true, must_change_password: false, + is_owner: false, }; const err401 = { status: 401, code: "unauthorized", message: "no session" }; diff --git a/panel/src/lib/nav.test.ts b/panel/src/lib/nav.test.ts index 23af294..9340f54 100644 --- a/panel/src/lib/nav.test.ts +++ b/panel/src/lib/nav.test.ts @@ -8,32 +8,34 @@ import { NAV_SECTIONS, visibleSections } from "./nav"; describe("visibleSections", () => { 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"]); }); it("treats the fail-closed default (false) exactly like a non-admin", () => { // TierProvider passes `false` while /me is loading or after it rejects. That // path MUST collapse to User-Side only, never leak Admin/Ops nav. - expect(visibleSections(false)).toHaveLength(1); - expect(visibleSections(false)[0].id).toBe("user"); + expect(visibleSections(false, false)).toHaveLength(1); + expect(visibleSections(false, false)[0].id).toBe("user"); }); 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"]); }); it("keeps the User-Side section ungated so it survives both branches", () => { const user = NAV_SECTIONS.find((s) => s.id === "user"); expect(user?.adminOnly).toBe(false); - expect(visibleSections(true)).toContainEqual(user); - expect(visibleSections(false)).toContainEqual(user); + expect(visibleSections(true, 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) { - if (s.id !== "user") expect(s.adminOnly).toBe(true); + if (s.id !== "user") { + expect(s.adminOnly || s.ownerOnly).toBe(true); + } } }); }); diff --git a/panel/src/lib/viewmode.test.ts b/panel/src/lib/viewmode.test.ts index ef0bea8..40f9b7c 100644 --- a/panel/src/lib/viewmode.test.ts +++ b/panel/src/lib/viewmode.test.ts @@ -10,63 +10,51 @@ import { } from "./viewmode"; 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", () => { 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", () => { - // TierProvider passes `false` while /me loads or after it rejects; the switcher - // must offer nothing but the user home in that window. - expect(availableViewModes(false)).toEqual(["user"]); + expect(availableViewModes(false, false)).toEqual(["user"]); }); - it("offers an admin every home, ordered least- to most-revealing", () => { - expect(availableViewModes(true)).toEqual(["user", "admin"]); + it("offers an admin every home except owner", () => { + expect(availableViewModes(true, false)).toEqual(["user", "admin"]); }); - it("returns a fresh array so a caller cannot mutate the canonical list", () => { - const a = availableViewModes(true); - a.push("user"); - expect(availableViewModes(true)).toEqual(["user", "admin"]); + it("offers an owner every home including owner", () => { + expect(availableViewModes(true, true)).toEqual(["user", "admin", "owner"]); }); }); describe("VIEW_MODES", () => { - it("is the two homes ordered by how much they reveal", () => { - expect(VIEW_MODES).toEqual(["user", "admin"]); + it("is the three homes ordered by how much they reveal", () => { + expect(VIEW_MODES).toEqual(["user", "admin", "owner"]); }); }); describe("effectiveViewMode (fail-closed resolution)", () => { 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", () => { - expect(effectiveViewMode("user", true)).toBe("user"); - expect(effectiveViewMode("admin", true)).toBe("admin"); + expect(effectiveViewMode("user", true, false)).toBe("user"); + 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", () => { - expect(effectiveViewMode(null, true)).toBe("user"); - expect(effectiveViewMode(undefined, true)).toBe("user"); - expect(effectiveViewMode(null, 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"); + expect(effectiveViewMode(null, true, false)).toBe("user"); + expect(effectiveViewMode(undefined, true, false)).toBe("user"); + expect(effectiveViewMode(null, false, false)).toBe("user"); }); }); @@ -74,12 +62,13 @@ describe("parseViewMode (shape only, no gating)", () => { it("accepts each known home verbatim", () => { expect(parseViewMode("user")).toBe("user"); expect(parseViewMode("admin")).toBe("admin"); + expect(parseViewMode("owner")).toBe("owner"); }); it("rejects anything that is not a known home, returning null", () => { expect(parseViewMode("ops")).toBeNull(); expect(parseViewMode("operator")).toBeNull(); - expect(parseViewMode("Admin")).toBeNull(); // case-sensitive on purpose + expect(parseViewMode("Admin")).toBeNull(); expect(parseViewMode("")).toBeNull(); expect(parseViewMode(null)).toBeNull(); expect(parseViewMode(undefined)).toBeNull(); @@ -89,62 +78,50 @@ describe("parseViewMode (shape only, no gating)", () => { }); 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", () => { - expect(restoreViewMode("admin", true)).toBe("admin"); - expect(restoreViewMode("user", true)).toBe("user"); + expect(restoreViewMode("admin", true, false)).toBe("admin"); + expect(restoreViewMode("user", true, false)).toBe("user"); }); 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)).toBe("user"); + expect(restoreViewMode("admin", false, false)).toBe("user"); }); - it("collapses a garbage stored value to user even for an admin", () => { - expect(restoreViewMode("ops", true)).toBe("user"); - expect(restoreViewMode("ops", false)).toBe("user"); + it("collapses a stored owner home to user for a non-owner", () => { + expect(restoreViewMode("owner", true, false)).toBe("user"); }); - it("collapses a malformed/garbage stored value to user even for an admin", () => { - expect(restoreViewMode("root", true)).toBe("user"); - expect(restoreViewMode("", true)).toBe("user"); - expect(restoreViewMode(null, true)).toBe("user"); - expect(restoreViewMode(42, true)).toBe("user"); + it("honours a stored owner home for an owner", () => { + expect(restoreViewMode("owner", true, true)).toBe("owner"); }); }); describe("sectionsForView (UX ceiling, composed on visibleSections)", () => { it("shows a non-admin only the User-Side regardless of the requested view", () => { - for (const v of ["user", "admin"] as ViewMode[]) { - expect(sectionsForView(v, false).map((s) => s.id)).toEqual(["user"]); + for (const v of ["user", "admin", "owner"] as ViewMode[]) { + expect(sectionsForView(v, false, false).map((s) => s.id)).toEqual(["user"]); } }); it("foregrounds homes up to the chosen ceiling for an admin", () => { - expect(sectionsForView("user", true).map((s) => s.id)).toEqual(["user"]); - expect(sectionsForView("admin", true).map((s) => s.id)).toEqual(["user", "admin"]); + expect(sectionsForView("user", true, false).map((s) => s.id)).toEqual(["user"]); + 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", () => { - // The new capability the switcher adds: an admin can choose to view the app as a - // plain user. visibleSections alone could never hide their admin nav; this can. - const ids = sectionsForView("user", true).map((s) => s.id); - expect(ids).toEqual(["user"]); - expect(ids).not.toContain("admin"); + it("foregrounds homes up to the chosen ceiling for an owner", () => { + expect(sectionsForView("user", true, true).map((s) => s.id)).toEqual(["user"]); + expect(sectionsForView("admin", true, true).map((s) => s.id)).toEqual(["user", "admin"]); + expect(sectionsForView("owner", true, true).map((s) => s.id)).toEqual(["user", "admin", "owner"]); }); it("never returns more than visibleSections already permits (subset invariant)", () => { - // The switcher only ever narrows. For every (view, isAdmin) pair the result must - // be a subset of visibleSections(isAdmin) — it can never widen access. - for (const isAdmin of [true, false]) { - const permitted = new Set(visibleSections(isAdmin).map((s) => s.id)); - for (const v of ["user", "admin"] as ViewMode[]) { - for (const s of sectionsForView(v, isAdmin)) { - expect(permitted.has(s.id)).toBe(true); + for (const isOwner of [true, false]) { + for (const isAdmin of [true, false]) { + const permitted = new Set(visibleSections(isAdmin, isOwner).map((s) => s.id)); + for (const v of ["user", "admin", "owner"] as ViewMode[]) { + for (const s of sectionsForView(v, isAdmin, isOwner)) { + expect(permitted.has(s.id)).toBe(true); + } } } } diff --git a/panel/src/lib/viewmode.ts b/panel/src/lib/viewmode.ts index d3f3dd6..7931b28 100644 --- a/panel/src/lib/viewmode.ts +++ b/panel/src/lib/viewmode.ts @@ -36,6 +36,7 @@ export type ViewMode = NavSection["id"]; const VIEW_RANK: Record = { user: 0, admin: 1, + owner: 2, }; /** 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 * avatar menu renders, and the allow-list effectiveViewMode resolves against. */ -export function availableViewModes(isAdmin: boolean): ViewMode[] { - return isAdmin ? [...VIEW_MODES] : ["user"]; +export function availableViewModes(isAdmin: boolean, isOwner: boolean): ViewMode[] { + 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( requested: ViewMode | null | undefined, isAdmin: boolean, + isOwner: boolean, ): ViewMode { - const allowed = availableViewModes(isAdmin); + const allowed = availableViewModes(isAdmin, isOwner); 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 * cannot slip past as a truthy non-ViewMode. */ -export function restoreViewMode(raw: unknown, isAdmin: boolean): ViewMode { - return effectiveViewMode(parseViewMode(raw), isAdmin); +export function restoreViewMode(raw: unknown, isAdmin: boolean, isOwner: boolean): ViewMode { + 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 * narrow the sidebar, never widen it past `visibleSections(isAdmin)`. */ -export function sectionsForView(view: ViewMode, isAdmin: boolean): NavSection[] { - const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin)]; - return visibleSections(isAdmin).filter((s) => VIEW_RANK[s.id] <= ceiling); +export function sectionsForView(view: ViewMode, isAdmin: boolean, isOwner: boolean): NavSection[] { + const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin, isOwner)]; + return visibleSections(isAdmin, isOwner).filter((s) => VIEW_RANK[s.id] <= ceiling); } diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx index 01498bb..9363447 100644 --- a/panel/src/pages/admin/UserDetailPage.tsx +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -57,7 +57,6 @@ export function UserDetailPage() { const { t, i18n } = useTranslation("admin"); const locale = i18n.language; const { identity } = useTier(); - const isSelf = identity?.user_id === id; const { data: user, error, loading, reload } = useAsync( () => api.getUser(id!), @@ -178,8 +177,12 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved: await api.patchUser(user.id, patch); setOk(t("users_save_ok")); onSaved(); - } catch (e) { - setErr(humanizeError(e)); + } catch (e: any) { + if (e && e.code === "already_exists") { + setErr(t("users_create_validation_username_taken") || "该用户名已被使用。"); + } else { + setErr(humanizeError(e)); + } } finally { setSaving(false); }