fix(panel): override generic already_exists error message during user creation and profile editing
This commit is contained in:
10 files changed
+234
-188
No files matched your search
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ViewMode = NavSection["id"];
|
||||
const VIEW_RANK: Record<ViewMode, number> = {
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user