feat(admin): browse Discord suggestions in the portal
This commit is contained in:
@@ -39,6 +39,7 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
||||
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
||||
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
|
||||
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
|
||||
<Link className="hover:text-accent" href="/admin/suggestions">Suggestions</Link>
|
||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||
</nav>
|
||||
<AdminSignOutButton />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SuggestionReader } from "@/components/suggestion-reader";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SuggestionPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
await requireAdminSession();
|
||||
const { id } = await params;
|
||||
if (!/^[1-9]\d{16,19}$/.test(id)) notFound();
|
||||
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<Link className="mb-8 inline-block font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4 hover:text-accent" href="/admin/suggestions">← All suggestions</Link>
|
||||
<SuggestionReader id={id} key={id} />
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it, vi } from "vitest";
|
||||
vi.mock("next-auth", () => ({ getServerSession: async () => ({ user: { roles: ["ops"] } }) }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("@minecraft-account-manager/database", () => ({ recordEvent: vi.fn() }));
|
||||
vi.mock("@/lib/database", () => ({ db: {} }));
|
||||
vi.mock("@/components/admin-sign-out-button", () => ({ AdminSignOutButton: () => <button>Sign out</button> }));
|
||||
import AdminConsoleLayout from "../layout";
|
||||
it("adds suggestions to administrator navigation", async () => {
|
||||
const markup = renderToStaticMarkup(await AdminConsoleLayout({ children: <p>Console</p> }));
|
||||
expect(markup).toContain('href="/admin/suggestions"');
|
||||
expect(markup).toContain("Suggestions</a>");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({ requireAdmin: vi.fn() }));
|
||||
vi.mock("@/lib/auth/require-admin", () => ({ requireAdminSession: mocks.requireAdmin }));
|
||||
vi.mock("@/components/suggestions-browser", () => ({ SuggestionsBrowser: () => <div>Suggestions browser</div> }));
|
||||
vi.mock("@/components/suggestion-reader", () => ({ SuggestionReader: ({ id }: { id: string }) => <div>Reader {id}</div> }));
|
||||
import SuggestionsPage from "./page";
|
||||
import SuggestionPage from "./[id]/page";
|
||||
const params = Promise.resolve({ id: "100000000000000009" });
|
||||
beforeEach(() => { mocks.requireAdmin.mockReset(); });
|
||||
it("independently requires the admin guard before rendering either page", async () => {
|
||||
mocks.requireAdmin.mockRejectedValue(new Error("Admin sign-in required"));
|
||||
await expect(SuggestionsPage()).rejects.toThrow("Admin sign-in required");
|
||||
await expect(SuggestionPage({ params })).rejects.toThrow("Admin sign-in required");
|
||||
});
|
||||
it("renders the browser and detail in the existing portal layout", async () => {
|
||||
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||
expect(renderToStaticMarkup(await SuggestionsPage())).toContain("Suggestions browser");
|
||||
const detail = renderToStaticMarkup(await SuggestionPage({ params }));
|
||||
expect(detail).toContain("Reader");
|
||||
expect(detail).toContain('href="/admin/suggestions"');
|
||||
});
|
||||
it("rejects malformed IDs rather than constructing arbitrary API URLs", async () => {
|
||||
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||
await expect(SuggestionPage({ params: Promise.resolve({ id: "../secret" }) })).rejects.toThrow("NEXT_HTTP_ERROR_FALLBACK;404");
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SuggestionsBrowser } from "@/components/suggestions-browser";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SuggestionsPage() {
|
||||
await requireAdminSession();
|
||||
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<header className="relative overflow-hidden border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community / Idea desk</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Suggestions</h1>
|
||||
<div className="mt-5 flex flex-wrap items-end justify-between gap-5">
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted">Ideas from the Discord forum, brought into the operations desk. Read proposals and discussion here; keep the conversation in Discord.</p>
|
||||
<span className="border border-ink bg-signal px-3 py-2 font-mono text-[10px] font-bold uppercase tracking-widest">Admin-only / Read-only</span>
|
||||
</div>
|
||||
</header>
|
||||
<SuggestionsBrowser />
|
||||
</main>;
|
||||
}
|
||||
Reference in New Issue
Block a user