feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
@@ -16,6 +18,7 @@ export default async function PermissionsPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const sp = (await searchParams) ?? {};
|
||||
const fromHousekeeping = sp.from === "housekeeping";
|
||||
|
||||
@@ -36,9 +39,14 @@ export default async function PermissionsPage({
|
||||
}));
|
||||
|
||||
return (
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/permissions/preview" className="btn btn-outline">
|
||||
{t("title")}
|
||||
</Link>
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" },
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 7 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: state.access,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("@/components/link", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
beforeEach(() => {
|
||||
state.access.mockReset().mockReturnValue(true);
|
||||
state.execute.mockReset();
|
||||
});
|
||||
it("rejects before querying role details", async () => {
|
||||
state.access.mockReturnValue(false);
|
||||
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
|
||||
"redirect",
|
||||
);
|
||||
expect(state.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not silently substitute an invalid requested role", async () => {
|
||||
state.execute.mockResolvedValueOnce([
|
||||
[{ id: 1, slug: "rank_1", title: "User" }],
|
||||
]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "99" }) }),
|
||||
);
|
||||
expect(html).toContain("missing");
|
||||
expect(state.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("simulates highest-rank override from live rank data", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "rank_8", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).toContain("superAdmin");
|
||||
expect(html).toContain("entryAllowed");
|
||||
});
|
||||
it("does not infer owner privileges from a title", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "owner", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).not.toContain("superAdmin");
|
||||
expect(html).toContain("entryDenied");
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { previewRoleAccess } from "@/lib/admin/permission-preview";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function PermissionPreview({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ role?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const nav = await getTranslations("pages.admin.nav");
|
||||
const [roleRows] = await db.execute(
|
||||
sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`,
|
||||
);
|
||||
const roles = roleRows as unknown as {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
}[];
|
||||
const params = await searchParams;
|
||||
const selected =
|
||||
roles.find((r) => String(r.id) === params.role) ??
|
||||
(params.role ? undefined : roles[0]);
|
||||
let preview: ReturnType<typeof previewRoleAccess> | undefined;
|
||||
let superAdmin = false;
|
||||
let permissionTitles: Record<string, string> = {};
|
||||
if (selected) {
|
||||
const [grantRows] = await db.execute(
|
||||
sql`SELECT p.slug, p.title FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id WHERE mp.model_type = 'Role' AND mp.model_id = ${selected.id}`,
|
||||
);
|
||||
const grants = grantRows as unknown as { slug: string; title: string }[];
|
||||
const [highestRows] = await db.execute(
|
||||
sql`SELECT COALESCE((SELECT MAX(u.\`rank\`) FROM users u INNER JOIN permission_ranks pr ON pr.id = u.\`rank\`), (SELECT MAX(id) FROM permission_ranks)) AS highest_rank`,
|
||||
);
|
||||
const highest = (
|
||||
highestRows as unknown as { highest_rank: number | null }[]
|
||||
)[0]?.highest_rank;
|
||||
const rankMatch = /^rank_([1-9]\d*)$/.exec(selected.slug);
|
||||
superAdmin =
|
||||
!!rankMatch &&
|
||||
isDynamicSuperAdmin(
|
||||
Number(rankMatch[1]),
|
||||
highest == null ? null : Number(highest),
|
||||
);
|
||||
preview = previewRoleAccess(
|
||||
grants.map((g) => g.slug),
|
||||
superAdmin,
|
||||
);
|
||||
const [allRows] = await db.execute(
|
||||
sql`SELECT slug, title FROM acl_permissions ORDER BY slug`,
|
||||
);
|
||||
permissionTitles = Object.fromEntries(
|
||||
(allRows as unknown as { slug: string; title: string }[]).map((p) => [
|
||||
p.slug,
|
||||
p.title,
|
||||
]),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Link className="btn btn-outline" href="/admin/permissions">
|
||||
{t("back")}
|
||||
</Link>
|
||||
<form className="admin-card flex flex-wrap items-end gap-3">
|
||||
<label className="grid min-w-0 max-w-full flex-1 gap-1 text-sm">
|
||||
{t("role")}
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={selected?.id ?? ""}
|
||||
className="input w-full min-w-0 max-w-full"
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.title} ({role.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={!roles.length}
|
||||
>
|
||||
{t("show")}
|
||||
</button>
|
||||
</form>
|
||||
{!preview ? (
|
||||
<p role="status">{t("missing")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-card space-y-2">
|
||||
<p className="font-semibold">
|
||||
{t(preview.canEnter ? "entryAllowed" : "entryDenied")}
|
||||
</p>
|
||||
{superAdmin && <p>{t("superAdmin")}</p>}
|
||||
<p className="text-sm text-muted-foreground">{t("scope")}</p>
|
||||
</div>
|
||||
<section className="admin-card">
|
||||
<h2 className="mb-3 font-semibold">{t("sections")}</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{preview.sections.map((item) => (
|
||||
<div key={item.href} className="min-w-0 rounded border p-3">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{nav(item.labelKey)}</span>
|
||||
<span className="text-xs">
|
||||
{t(item.allowed ? "allowed" : "denied")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{item.href}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<details className="admin-card">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
{t("actions")}
|
||||
</summary>
|
||||
<p className="my-3 text-sm text-muted-foreground">
|
||||
{t("actionScope")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{preview.actions.map((item) => (
|
||||
<li
|
||||
key={item.slug}
|
||||
className="flex items-start justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="break-words">
|
||||
{permissionTitles[item.slug] || item.slug}
|
||||
</p>
|
||||
<code className="break-all text-xs text-muted-foreground">
|
||||
{item.slug}
|
||||
</code>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs">
|
||||
{t(item.granted ? "granted" : "notGranted")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user