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
+4290 -1243

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";
import { eq } from "drizzle-orm";
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 { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
export async function dismissApplication(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
}
const staff = await requirePermission(PERMS.USERS_EDIT);
const rawId = formPositiveBigInt(formData, "id");
if (!rawId) return;
const applicationId = Number(rawId);
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
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");
}
File diff suppressed because it is too large. Load diff
+20 -53
View File
@@ -1,65 +1,32 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
createLegacyPeopleMutationContext,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const guildId = Number(formData.get("id"));
if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
const [guild] = await db
.select({
id: Guilds.id,
name: Guilds.name,
userId: Guilds.userId,
})
.from(Guilds)
.where(eq(Guilds.id, id))
.limit(1);
if (!guild) return;
await db.transaction(async (tx) => {
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,
});
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"guild.disband",
{ guildId },
);
if (!result.ok) {
if (result.error.code === "NOT_FOUND") return;
throw new Error("Could not disband guild");
}
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";
import { eq } from "drizzle-orm";
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 { db, WebsiteTeams } from "@/lib/db";
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> {
await requirePermission(PERMS.USERS_EDIT);
const rankName = String(formData.get("rankName") ?? "")
.normalize("NFC")
.trim();
const staff = await requirePermission(PERMS.USERS_EDIT);
const rankName = text(formData, "rankName");
if (!rankName) return;
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.trim();
const jobDescription = String(formData.get("jobDescription") ?? "")
.normalize("NFC")
.trim();
const staffColor =
String(formData.get("staffColor") ?? "")
.normalize("NFC")
.trim() || "#327fa8";
const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date();
await db.insert(WebsiteTeams).values({
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
});
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"team.change",
{
action: "create",
rankName,
badge: text(formData, "badge"),
jobDescription: text(formData, "jobDescription"),
staffColor: text(formData, "staffColor") || "#327fa8",
hiddenRank: formData.get("hiddenRank") === "on",
},
);
if (!result.ok) throw new Error("Could not create team");
revalidatePath("/admin/teams");
}
export async function deleteTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const id = BigInt(String(formData.get("id")));
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
const staff = await requirePermission(PERMS.USERS_EDIT);
const teamId = Number(formData.get("id"));
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
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");
}
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";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
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 { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionError,
actionOk,
} from "@/lib/safe-action-shared";
import { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
export async function addWord(input: {
word: string;
}): Promise<ActionResult<{ id: string }>> {
await requirePermission(PERMS.WORDFILTER_EDIT);
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const word = String(input.word ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!word) return actionError("Word is required");
try {
const [result] = (await db
.insert(WebsiteWordfilter)
.values({ word })) as unknown as [ResultSetHeader];
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(result.insertId) });
} catch {
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
"word-filter.update",
{ action: "add", word },
);
if (!result.ok)
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> {
await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(input.id ?? "").normalize("NFC");
if (!raw) return actionError("Missing word id");
try {
await db
.delete(WebsiteWordfilter)
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk();
} catch {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const id = Number(String(input.id ?? "").normalize("NFC"));
if (!Number.isSafeInteger(id) || id <= 0)
return actionError("Missing word id");
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
"word-filter.update",
{ action: "delete", id },
);
if (!result.ok && result.error.code !== "NOT_FOUND") {
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 { 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", () => {
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("CameraWeb");
expect(src).toContain("tryRemoveLocalPhotoFile");
expect(src).toContain("@/lib/db");
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,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
import { PEOPLE_PRIMARY_ROUTES } from "./routes";
export const peopleManifest = {
id: "people",
@@ -34,7 +35,7 @@ export const peopleManifest = {
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
),
routes: [],
routes: PEOPLE_PRIMARY_ROUTES,
searchProviders: [],
inboxSources: [],
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],
),
] 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";
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 { anyCapability, ok } from "../contracts";
import {
@@ -60,6 +66,12 @@ describe("housekeeping command bootstrap", () => {
expect(
SYSTEM_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
).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(() =>
registerHousekeepingCommand({
id: "system.bootstrap.too-late",
@@ -1,5 +1,7 @@
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 type { HousekeepingCommand } from "./registry";
import {
@@ -36,6 +38,8 @@ export function registerHousekeepingCommands<
}
const currentHousekeepingCommands = defineHousekeepingCommands(
...USER_COMMANDS,
...COMMUNITY_COMMANDS,
...SYSTEM_COMMANDS,
);
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { PEOPLE_PRIMARY_ROUTES } from "../domains/people/routes";
import { SYSTEM_ROUTES } from "../domains/system/routes";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
@@ -355,7 +356,11 @@ describe("housekeeping registry", () => {
descriptionKey: expected.descriptionKey,
});
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.inboxSources).toEqual([]);
@@ -74,12 +74,12 @@ function adminContext(
};
}
async function invokeRequestConsumers() {
async function invokeRequestConsumers(expectedPageError = "NEXT_NOT_FOUND") {
const shell = await AdminNextDomainLayout({
children: null,
params: Promise.resolve({ domain: "operations" }),
});
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
await expect(AdminNextPage()).rejects.toThrow(expectedPageError);
const commandContext = await requireHousekeepingCapability(
anyCapability("admin.dashboard"),
);
@@ -102,7 +102,9 @@ describe("getHousekeepingCapabilityContext", () => {
});
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.commandContext.actor).toEqual({
@@ -1,4 +1,5 @@
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 { createHousekeepingRegistry } from "./foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
@@ -16,6 +17,9 @@ describe("housekeeping route handlers", () => {
expect(new Set(handlerIds).size).toBe(handlerIds.length);
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 { PEOPLE_PRIMARY_ROUTE_HANDLERS } from "./domains/people/route-handlers";
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
@@ -14,4 +15,4 @@ export interface 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": {
"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": {
"access": {
"permissions": "Permissions",
+17
View File
@@ -3300,6 +3300,23 @@
}
},
"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": {
"access": {
"permissions": "Permessi",