feat(housekeeping): deliver people account workflows

This commit is contained in:
Simo committed 2026-08-29 11:45:50 +02:00
1 parent 7920d4f46c
commit e1b31ff773
45 files changed
+4278 -1231

No files matched your search

File diff suppressed because it is too large. Load diff
+20 -13
View File
@@ -1,24 +1,31 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data"; import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
export async function dismissApplication(formData: FormData): Promise<void> { export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id"); const rawId = formPositiveBigInt(formData, "id");
if (!id) return; if (!rawId) return;
const applicationId = Number(rawId);
try { if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
}
await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"application.decide",
{ applicationId, decision: "dismiss" },
);
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
revalidatePath("/admin/applications"); revalidatePath("/admin/applications");
} }
File diff suppressed because it is too large. Load diff
+19 -52
View File
@@ -1,65 +1,32 @@
"use server"; "use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { import {
db, createLegacyPeopleMutationContext,
GuildForumViews, peopleMutationService,
Guilds, } from "@/features/housekeeping/domains/people/services/mutations";
GuildsForumsComments, import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
GuildsForumsThreads, import { requirePermissionRateLimited } from "@/lib/admin/guard";
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */ /** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> { export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = Number(formData.get("id")); const guildId = Number(formData.get("id"));
if (!(id > 0)) return; if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
const [guild] = await db const result = await peopleMutationService.execute(
.select({ createLegacyPeopleMutationContext(
id: Guilds.id, staff,
name: Guilds.name, PERMS.USERS_EDIT,
userId: Guilds.userId, createCorrelationId(),
}) ),
.from(Guilds) "guild.disband",
.where(eq(Guilds.id, id)) { guildId },
.limit(1); );
if (!guild) return; if (!result.ok) {
if (result.error.code === "NOT_FOUND") return;
await db.transaction(async (tx) => { throw new Error("Could not disband guild");
const threads = await tx
.select({ id: GuildsForumsThreads.id })
.from(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) {
await tx
.delete(GuildsForumsComments)
.where(inArray(GuildsForumsComments.threadId, threadIds));
await tx
.delete(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
} }
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
await tx.delete(Guilds).where(eq(Guilds.id, id));
});
await logStaffActivity({
staffId: staff.id,
action: "guild_disband",
description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`,
targetType: "guild",
targetId: id,
});
revalidatePath("/admin/guilds"); revalidatePath("/admin/guilds");
} }
File diff suppressed because it is too large. Load diff
+42 -51
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+45 -36
View File
@@ -1,50 +1,59 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteTeams } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
function text(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
}
export async function createTeam(formData: FormData): Promise<void> { export async function createTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const rankName = text(formData, "rankName");
const rankName = String(formData.get("rankName") ?? "")
.normalize("NFC")
.trim();
if (!rankName) return; if (!rankName) return;
const result = await peopleMutationService.execute(
const badge = String(formData.get("badge") ?? "") createLegacyPeopleMutationContext(
.normalize("NFC") staff,
.trim(); PERMS.USERS_EDIT,
const jobDescription = String(formData.get("jobDescription") ?? "") createCorrelationId(),
.normalize("NFC") ),
.trim(); "team.change",
const staffColor = {
String(formData.get("staffColor") ?? "") action: "create",
.normalize("NFC") rankName,
.trim() || "#327fa8"; badge: text(formData, "badge"),
const hiddenRank = formData.get("hiddenRank") === "on"; jobDescription: text(formData, "jobDescription"),
staffColor: text(formData, "staffColor") || "#327fa8",
const now = new Date(); hiddenRank: formData.get("hiddenRank") === "on",
await db.insert(WebsiteTeams).values({ },
rankName: rankName.slice(0, 255), );
badge: badge ? badge.slice(0, 255) : null, if (!result.ok) throw new Error("Could not create team");
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
});
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
} }
export async function deleteTeam(formData: FormData): Promise<void> { export async function deleteTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const teamId = Number(formData.get("id"));
const id = BigInt(String(formData.get("id"))); if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id)); const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"team.change",
{ action: "delete", teamId },
);
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not delete team");
}
revalidatePath("/admin/teams"); revalidatePath("/admin/teams");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34 -30
View File
@@ -1,56 +1,60 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
type ActionResult, type ActionResult,
actionError, actionError,
actionOk, actionOk,
} from "@/lib/safe-action-shared"; } from "@/lib/safe-action-shared";
import { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
export async function addWord(input: { export async function addWord(input: {
word: string; word: string;
}): Promise<ActionResult<{ id: string }>> { }): Promise<ActionResult<{ id: string }>> {
await requirePermission(PERMS.WORDFILTER_EDIT); const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const word = String(input.word ?? "") const word = String(input.word ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
.slice(0, 255); .slice(0, 255);
if (!word) return actionError("Word is required"); if (!word) return actionError("Word is required");
const result = await peopleMutationService.execute(
try { createLegacyPeopleMutationContext(
const [result] = (await db staff,
.insert(WebsiteWordfilter) PERMS.WORDFILTER_EDIT,
.values({ word })) as unknown as [ResultSetHeader]; createCorrelationId(),
reloadWordFilter(); ),
await rcon.updateWordFilter(); "word-filter.update",
revalidatePath("/admin/wordfilter"); { action: "add", word },
return actionOk({ id: String(result.insertId) }); );
} catch { if (!result.ok)
return actionError("Could not add word (it may already exist)"); return actionError("Could not add word (it may already exist)");
} revalidatePath("/admin/wordfilter");
return actionOk({ id: String(result.data.after?.id ?? "") });
} }
export async function deleteWord(input: { id: string }): Promise<ActionResult> { export async function deleteWord(input: { id: string }): Promise<ActionResult> {
await requirePermission(PERMS.WORDFILTER_EDIT); const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(input.id ?? "").normalize("NFC"); const id = Number(String(input.id ?? "").normalize("NFC"));
if (!raw) return actionError("Missing word id"); if (!Number.isSafeInteger(id) || id <= 0)
return actionError("Missing word id");
try { const result = await peopleMutationService.execute(
await db createLegacyPeopleMutationContext(
.delete(WebsiteWordfilter) staff,
.where(eq(WebsiteWordfilter.id, BigInt(raw))); PERMS.WORDFILTER_EDIT,
reloadWordFilter(); createCorrelationId(),
await rcon.updateWordFilter(); ),
revalidatePath("/admin/wordfilter"); "word-filter.update",
return actionOk(); { action: "delete", id },
} catch { );
if (!result.ok && result.error.code !== "NOT_FOUND") {
return actionError("Could not remove word"); return actionError("Could not remove word");
} }
revalidatePath("/admin/wordfilter");
return actionOk();
} }
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("@/lib/db", () => ({ db: {}, User: {}, UsersCurrency: {} }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
import { bulkAdjustCurrency } from "./bulk-users";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
} as never);
execute.mockResolvedValue({
ok: true,
data: {
before: { userIds: [7, 8] },
after: { completed: 2, total: 2, failedIds: [] },
},
correlationId: "bulk-adjust",
});
});
it("keeps one ACL check while delegating a positive bulk adjustment", async () => {
await expect(
bulkAdjustCurrency({
userIds: [7, 8],
amount: 25,
type: "credits",
}),
).resolves.toEqual({
ok: true,
data: { adjusted: 2, total: 2, failedIds: [] },
});
expect(requirePermission).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"users.bulk-currency",
{ userIds: [7, 8], amount: 25, type: "credits" },
);
});
File diff suppressed because it is too large. Load diff
+114 -225
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { execute, staff } = vi.hoisted(() => ({
execute: vi.fn(),
staff: { id: 1, rank: 7, username: "admin" },
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(actor, permission, correlationId) => ({
actor,
permission,
correlationId,
}),
),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: vi.fn(async () => staff),
requirePermissionRateLimited: vi.fn(async () => staff),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
SETTINGS_EDIT: "admin.settings.edit",
USERS_EDIT: "admin.users.edit",
WORDFILTER_EDIT: "admin.wordfilter.edit",
},
}));
vi.mock("@/lib/safe-action-shared", () => ({
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
actionError: (error: string) => ({ ok: false, error }),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { disbandGuild } from "./admin-guilds";
import { addWhitelist } from "./admin-ip";
import { createTeam, deleteTeam } from "./admin-teams";
import { deleteWord } from "./admin-wordfilter";
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const failure = (code: string) => ({
ok: false as const,
error: { code, messageKey: "errors.housekeeping.dependencyUnavailable" },
correlationId: "wrapper-failure",
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("legacy wrapper failure compatibility", () => {
it("keeps guild persistence failures throwing while a missing guild remains a no-op", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(disbandGuild(form({ id: "9" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(disbandGuild(form({ id: "9" }))).resolves.toBeUndefined();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps IP and team persistence failures throwing", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(
addWhitelist(form({ ipAddress: "192.0.2.1" })),
).rejects.toThrow();
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(createTeam(form({ rankName: "Moderator" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps already-gone team and word-filter deletes successful", async () => {
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteTeam(form({ id: "42" }))).resolves.toBeUndefined();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteWord({ id: "42" })).resolves.toEqual({
ok: true,
data: {},
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
});
});
-25
View File
@@ -2,30 +2,6 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
describe("setTradeLock drizzle + RCON contract", () => {
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8");
it("writes sanctions + users_settings via Drizzle", () => {
expect(src).toContain("@/lib/db");
expect(src).toContain("UsersSettings");
expect(src).toContain("Sanctions");
expect(src).toContain("canTrade");
expect(src).toContain("tradeLockedUntil");
expect(src).toMatch(/export async function setTradeLock/);
const fn = src.slice(src.indexOf("export async function setTradeLock"));
expect(fn).toContain("db.");
});
it("syncs live hotel via RCON settradelock + alert + disconnect", () => {
expect(rconSrc).toContain("settradelock");
expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)");
expect(src).toContain("rcon.setTradeLock");
expect(src).toContain("rcon.alertUser");
expect(src).toContain("rcon.disconnectUser");
});
});
describe("admin-photos drizzle contract", () => { describe("admin-photos drizzle contract", () => {
const src = readFileSync("src/actions/admin-photos.ts", "utf8"); const src = readFileSync("src/actions/admin-photos.ts", "utf8");
@@ -33,7 +9,6 @@ describe("admin-photos drizzle contract", () => {
expect(src).toContain("@/lib/db"); expect(src).toContain("@/lib/db");
expect(src).toContain("CameraWeb"); expect(src).toContain("CameraWeb");
expect(src).toContain("tryRemoveLocalPhotoFile"); expect(src).toContain("tryRemoveLocalPhotoFile");
expect(src).toContain("@/lib/db");
expect(src).toContain('revalidatePath("/photos")'); expect(src).toContain('revalidatePath("/photos")');
}); });
}); });
+145 -353
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -3,6 +3,7 @@ import {
anyCapability, anyCapability,
type HousekeepingDomainManifest, type HousekeepingDomainManifest,
} from "../../foundation/contracts"; } from "../../foundation/contracts";
import { PEOPLE_PRIMARY_ROUTES } from "./routes";
export const peopleManifest = { export const peopleManifest = {
id: "people", id: "people",
@@ -34,7 +35,7 @@ export const peopleManifest = {
PERMS.MOD_CFH_EDIT, PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT,
), ),
routes: [], routes: PEOPLE_PRIMARY_ROUTES,
searchProviders: [], searchProviders: [],
inboxSources: [], inboxSources: [],
widgets: [], widgets: [],
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,68 @@
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state";
interface PeopleMultiAccountsPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleMultiAccountsPage({
context: _context,
result,
partialDependencies,
}: PeopleMultiAccountsPageProps) {
return (
<PeoplePageFrame
title="Multi-account clusters"
description="Review bounded account clusters grouped by current IP."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) =>
data.kind === "multi-accounts" && data.page.items.length === 0
}
>
{(data) =>
data.kind === "multi-accounts" ? (
<ul className="space-y-3">
{data.page.items.map((cluster) => (
<li
key={cluster.key}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2 className="font-medium text-[var(--admin-text)]">
{cluster.key}
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{cluster.accountCount} accounts
</p>
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
{cluster.accounts.map((account) => (
<li key={account.id}>
<a href={account.href}>{account.username}</a>
</li>
))}
</ul>
</li>
))}
</ul>
) : null
}
</PeoplePageFrame>
);
}
export async function renderPeopleMultiAccountsPage(
input: HousekeepingPageInput,
) {
const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.multi-accounts",
list: {},
});
return <PeopleMultiAccountsPage context={input.context} result={result} />;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,83 @@
import { PERMS } from "@/lib/permission-slugs";
import {
createCorrelationId,
fail,
type HousekeepingCapabilityContext,
type HousekeepingResult,
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleStaffQueryData, peopleStaffQuery } from "../queries/staff";
import { PeoplePageFrame } from "./page-state";
interface PeopleStaffPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleStaffQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleStaffPage({
context,
result,
partialDependencies,
}: PeopleStaffPageProps) {
return (
<PeoplePageFrame
title="Staff"
description="Review staff applications and team definitions."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.page.items.length === 0}
>
{(data) =>
data.kind === "applications" ? (
<ul className="space-y-2">
{data.page.items.map((application) => (
<li
key={application.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2>{application.username ?? `User #${application.userId}`}</h2>
<p>{application.content}</p>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.application.decide">
Dismiss application
</span>
) : null}
</li>
))}
</ul>
) : data.kind === "teams" ? (
<ul className="space-y-2">
{data.page.items.map((team) => (
<li
key={team.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2>{team.name}</h2>
<p>{team.jobDescription ?? "No job description"}</p>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.team.change">
Team controls
</span>
) : null}
</li>
))}
</ul>
) : null
}
</PeoplePageFrame>
);
}
export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId;
const result =
routeId === "people.staff.applications" || routeId === "people.staff.teams"
? await peopleStaffQuery.run(input.context, { routeId, list: {} })
: fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
createCorrelationId(),
);
return <PeopleStaffPage context={input.context} result={result} />;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,86 @@
import { PERMS } from "@/lib/permission-slugs";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state";
interface PeopleUsersPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleUsersPage({
context,
result,
partialDependencies,
}: PeopleUsersPageProps) {
return (
<PeoplePageFrame
title="Users"
description="Search accounts and open the canonical People workflow."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
>
{(data) =>
data.kind === "users" ? (
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<p className="text-sm text-[var(--admin-text-muted)]">
{data.page.total} matching users
</p>
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
{data.page.items.map((user) => (
<li
key={user.id}
className="flex flex-wrap items-center gap-3 py-3 text-sm"
>
<a
className="font-medium text-[var(--admin-text)]"
href={user.href}
>
{user.username}
</a>
<span className="text-[var(--admin-text-muted)]">
Rank {user.rank}
</span>
<span className="text-[var(--admin-text-muted)]">
{user.online ? "Online" : "Offline"}
</span>
{user.mail !== null ? <span>{user.mail}</span> : null}
{user.ipCurrent !== null ? (
<span>{user.ipCurrent}</span>
) : null}
{context.has(PERMS.USERS_EDIT) ? (
<a
href={`${user.href}/edit`}
data-housekeeping-command="people.user.update"
>
Edit
</a>
) : null}
{context.has(PERMS.USERS_BAN) ? (
<span data-housekeeping-command="people.user.ban">
Ban controls available
</span>
) : null}
</li>
))}
</ul>
</section>
) : null
}
</PeoplePageFrame>
);
}
export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.list",
list: {},
});
return <PeopleUsersPage context={input.context} result={result} />;
}
@@ -0,0 +1,40 @@
import type { HousekeepingRouteHandler } from "../../route-handlers";
import { renderPeopleCommunityPage } from "./pages/community";
import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts";
import { renderPeopleStaffPage } from "./pages/staff";
import { renderPeopleUserDetailPage } from "./pages/user-detail";
import { renderPeopleUserEditPage } from "./pages/user-edit";
import { renderPeopleUsersPage } from "./pages/users";
export const PEOPLE_PRIMARY_ROUTE_IDS = [
"people.users.list",
"people.users.edit",
"people.users.multi-accounts",
"people.users.detail",
"people.community.online",
"people.community.guilds",
"people.community.guild-detail",
"people.staff.applications",
"people.staff.teams",
] as const;
type PeoplePrimaryRouteId = (typeof PEOPLE_PRIMARY_ROUTE_IDS)[number];
function rendererFor(
routeId: PeoplePrimaryRouteId,
): HousekeepingRouteHandler["render"] {
if (routeId === "people.users.list") return renderPeopleUsersPage;
if (routeId === "people.users.edit") return renderPeopleUserEditPage;
if (routeId === "people.users.multi-accounts")
return renderPeopleMultiAccountsPage;
if (routeId === "people.users.detail") return renderPeopleUserDetailPage;
if (routeId.startsWith("people.community.")) return renderPeopleCommunityPage;
return renderPeopleStaffPage;
}
export const PEOPLE_PRIMARY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze(
PEOPLE_PRIMARY_ROUTE_IDS.map((routeId) =>
Object.freeze({ routeId, render: rendererFor(routeId) }),
),
);
@@ -157,3 +157,5 @@ export const PEOPLE_ROUTES = [
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
), ),
] as const satisfies readonly HousekeepingRouteDefinition[]; ] as const satisfies readonly HousekeepingRouteDefinition[];
export const PEOPLE_PRIMARY_ROUTES = Object.freeze(PEOPLE_ROUTES.slice(0, 9));
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands";
import { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands";
import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands"; import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
import { anyCapability, ok } from "../contracts"; import { anyCapability, ok } from "../contracts";
import { import {
@@ -60,6 +66,12 @@ describe("housekeeping command bootstrap", () => {
expect( expect(
SYSTEM_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id), SYSTEM_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
).toEqual(SYSTEM_COMMAND_IDS); ).toEqual(SYSTEM_COMMAND_IDS);
expect(
USER_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
).toEqual(USER_COMMAND_IDS);
expect(
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
).toEqual(COMMUNITY_COMMAND_IDS);
expect(() => expect(() =>
registerHousekeepingCommand({ registerHousekeepingCommand({
id: "system.bootstrap.too-late", id: "system.bootstrap.too-late",
@@ -1,5 +1,7 @@
import "server-only"; import "server-only";
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
import { USER_COMMANDS } from "../../domains/people/commands/user-commands";
import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands"; import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands";
import type { HousekeepingCommand } from "./registry"; import type { HousekeepingCommand } from "./registry";
import { import {
@@ -36,6 +38,8 @@ export function registerHousekeepingCommands<
} }
const currentHousekeepingCommands = defineHousekeepingCommands( const currentHousekeepingCommands = defineHousekeepingCommands(
...USER_COMMANDS,
...COMMUNITY_COMMANDS,
...SYSTEM_COMMANDS, ...SYSTEM_COMMANDS,
); );
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { PEOPLE_PRIMARY_ROUTES } from "../domains/people/routes";
import { SYSTEM_ROUTES } from "../domains/system/routes"; import { SYSTEM_ROUTES } from "../domains/system/routes";
import { HOUSEKEEPING_MANIFESTS } from "../manifests"; import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
@@ -355,7 +356,11 @@ describe("housekeeping registry", () => {
descriptionKey: expected.descriptionKey, descriptionKey: expected.descriptionKey,
}); });
expect(actual.routes).toEqual( expect(actual.routes).toEqual(
expected.id === "system" ? SYSTEM_ROUTES : [], expected.id === "people"
? PEOPLE_PRIMARY_ROUTES
: expected.id === "system"
? SYSTEM_ROUTES
: [],
); );
expect(actual.searchProviders).toEqual([]); expect(actual.searchProviders).toEqual([]);
expect(actual.inboxSources).toEqual([]); expect(actual.inboxSources).toEqual([]);
@@ -74,12 +74,12 @@ function adminContext(
}; };
} }
async function invokeRequestConsumers() { async function invokeRequestConsumers(expectedPageError = "NEXT_NOT_FOUND") {
const shell = await AdminNextDomainLayout({ const shell = await AdminNextDomainLayout({
children: null, children: null,
params: Promise.resolve({ domain: "operations" }), params: Promise.resolve({ domain: "operations" }),
}); });
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND"); await expect(AdminNextPage()).rejects.toThrow(expectedPageError);
const commandContext = await requireHousekeepingCapability( const commandContext = await requireHousekeepingCapability(
anyCapability("admin.dashboard"), anyCapability("admin.dashboard"),
); );
@@ -102,7 +102,9 @@ describe("getHousekeepingCapabilityContext", () => {
}); });
it("isolates real shell, page, and command preflight consumers between logical requests", async () => { it("isolates real shell, page, and command preflight consumers between logical requests", async () => {
const first = await invokeRequestConsumers(); const first = await invokeRequestConsumers(
"NEXT_REDIRECT:/ase-next/people/users",
);
expect(first.shell).toBeDefined(); expect(first.shell).toBeDefined();
expect(first.commandContext.actor).toEqual({ expect(first.commandContext.actor).toEqual({
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PEOPLE_PRIMARY_ROUTE_IDS } from "./domains/people/route-handlers";
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes"; import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
import { createHousekeepingRegistry } from "./foundation/registry"; import { createHousekeepingRegistry } from "./foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "./manifests"; import { HOUSEKEEPING_MANIFESTS } from "./manifests";
@@ -16,6 +17,9 @@ describe("housekeeping route handlers", () => {
expect(new Set(handlerIds).size).toBe(handlerIds.length); expect(new Set(handlerIds).size).toBe(handlerIds.length);
expect([...handlerIds].sort()).toEqual([...routeIds].sort()); expect([...handlerIds].sort()).toEqual([...routeIds].sort());
expect(handlerIds).toEqual(SYSTEM_ROUTE_IDS); expect(handlerIds).toEqual([
...PEOPLE_PRIMARY_ROUTE_IDS,
...SYSTEM_ROUTE_IDS,
]);
}); });
}); });
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { PEOPLE_PRIMARY_ROUTE_HANDLERS } from "./domains/people/route-handlers";
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers"; import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
import type { HousekeepingCapabilityContext } from "./foundation/contracts"; import type { HousekeepingCapabilityContext } from "./foundation/contracts";
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route"; import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
@@ -14,4 +15,4 @@ export interface HousekeepingRouteHandler {
} }
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze([...SYSTEM_ROUTE_HANDLERS]); Object.freeze([...PEOPLE_PRIMARY_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
+17
View File
@@ -3295,6 +3295,23 @@
} }
}, },
"routes": { "routes": {
"people": {
"users": {
"list": "Users",
"edit": "Edit user",
"multi-accounts": "Multi-account clusters",
"detail": "User details"
},
"community": {
"online": "Online users",
"guilds": "Guilds",
"guild-detail": "Guild details"
},
"staff": {
"applications": "Staff applications",
"teams": "Staff teams"
}
},
"system": { "system": {
"access": { "access": {
"permissions": "Permissions", "permissions": "Permissions",
+17
View File
@@ -3300,6 +3300,23 @@
} }
}, },
"routes": { "routes": {
"people": {
"users": {
"list": "Utenti",
"edit": "Modifica utente",
"multi-accounts": "Gruppi multi-account",
"detail": "Dettagli utente"
},
"community": {
"online": "Utenti online",
"guilds": "Gruppi",
"guild-detail": "Dettagli gruppo"
},
"staff": {
"applications": "Candidature staff",
"teams": "Team staff"
}
},
"system": { "system": {
"access": { "access": {
"permissions": "Permessi", "permissions": "Permessi",