feat(housekeeping): deliver people account workflows
This commit is contained in:
1 parent
7920d4f46c
commit
e1b31ff773
45 files changed
+4290
-1243
No files matched your search
@@ -0,0 +1,155 @@
|
||||
# Task 12 — People users, community, and staff workflows
|
||||
|
||||
Status: DONE
|
||||
|
||||
## Delivered scope
|
||||
|
||||
- Registered exactly the nine approved real People routes: users list/edit/multi-account/detail, community online/guilds/guild detail, and staff applications/teams. The remaining support and moderation routes stay catalogued in `routes.ts` but unregistered for Task 13.
|
||||
- Added query-backed People pages with explicit loading, empty, partial, dependency-error, forbidden, and ready states. Links are canonical `/ase/people/*` links; optional mail/IP fields and mutation affordances remain absent unless their exact capability is present.
|
||||
- Added the exact fourteen user command IDs plus the six stable People-owned IDs `people.guild.disband`, `people.application.decide`, `people.team.change`, `people.ip.action`, `people.vpn.configure`, and `people.word-filter.update`.
|
||||
- Added bounded Zod command schemas, stable rate limits, dispatcher capability rechecks, confirmation metadata, a redirect-free server-only mutation service, and deterministic bootstrap registration.
|
||||
- Extracted shared mutation behavior behind the existing actions while preserving the legacy action exports, exact ACLs, `/admin` revalidation, VPN redirect/fail-soft behavior, word-filter `ActionResult` shapes, already-gone delete semantics, and failure propagation where legacy persistence errors previously propagated.
|
||||
- Added neutral EN/IT labels only for the nine runtime routes.
|
||||
|
||||
## Security and behavior decisions
|
||||
|
||||
- No ACL slug or route authorization rank threshold was added. Exact legacy capabilities remain authoritative: single ban/unban use `USERS_BAN`, reset-password uses `USERS_RESET_PASSWORD`, bulk/user/community/team/application operations use `USERS_EDIT`, IP/VPN use `SETTINGS_EDIT`, and word filter uses `WORDFILTER_EDIT`.
|
||||
- The existing target hierarchy safeguard remains for legacy user mutations that previously used `guardRank`; alert remains capability-authorized without a new target-rank rule.
|
||||
- Ordinary user edit and alert remain reason-free because their existing semantics are non-destructive. Sanctions, destructive operations, global/security changes, currency delivery, and bulk mutations require a nonblank dispatcher reason. Ban and bulk-ban operational reasons are also persisted with the mutation audit evidence.
|
||||
- Every public service call rechecks the exact capability before production work. The production adapter is module-private; server-only placement is not treated as authorization.
|
||||
- Successful mutations emit before/after audit evidence and a stable correlation ID. Audit persistence failure is fail-closed and maps to `DEPENDENCY_UNAVAILABLE`. User mutation audit snapshots omit mail because it is unnecessary PII; page projection continues to follow Task 11 exactly.
|
||||
- User pages consume only Task 11 guarded read models, preserving bounded pagination, deterministic sorting, fail-closed DTO validation, serialization, zero-sentinel rules, watched state, permission context, and PII projection.
|
||||
- Legacy wrappers remain on `/admin` behavior until Task 25. No `/admin`, `/mod`, API, redirect, database schema, deployment, or cutover behavior was changed.
|
||||
|
||||
## Strict TDD evidence
|
||||
|
||||
### Initial command/page/route RED
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/commands/user-commands.test.ts src/features/housekeeping/domains/people/commands/community-commands.test.ts src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx
|
||||
Test Files 3 failed (3)
|
||||
Tests 0
|
||||
Missing modules: community-commands, ../services/mutations, ../route-handlers
|
||||
```
|
||||
|
||||
Initial focused GREEN:
|
||||
|
||||
```text
|
||||
Command tests: 2 files passed, 22 tests passed
|
||||
Primary page/route tests: 3 files passed, 12 tests passed
|
||||
Bootstrap tests: 1 file passed, 2 tests passed
|
||||
```
|
||||
|
||||
### Foundation integration RED/GREEN
|
||||
|
||||
RED after enabling People:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation
|
||||
Test Files 3 failed | 31 passed
|
||||
Tests 4 failed | 376 passed
|
||||
Failures: stale System-only registry assertions, stale preview expectation, and an unapproved People vertical runtime edge.
|
||||
```
|
||||
|
||||
GREEN after updating the explicit runtime-edge and registry contracts:
|
||||
|
||||
```text
|
||||
Focused foundation contracts: 3 files passed, 40 tests passed
|
||||
People + foundation: 34 files passed, 380 tests passed
|
||||
```
|
||||
|
||||
### Audited sanction reason
|
||||
|
||||
RED:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/services/mutations-reason-production.test.ts
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed (1)
|
||||
The ban audit after-snapshot did not contain the nonblank sanction reason.
|
||||
```
|
||||
|
||||
GREEN:
|
||||
|
||||
```text
|
||||
Production mutation contracts: 2 files passed, 4 tests passed
|
||||
The audited snapshot includes the reason and excludes mail.
|
||||
```
|
||||
|
||||
### Legacy wrapper failure parity
|
||||
|
||||
RED:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage.enabled=false src/actions/people-wrapper-errors.test.ts
|
||||
Test Files 1 failed (1)
|
||||
Tests 3 failed (3)
|
||||
Persistence failures were swallowed and already-gone word-filter deletion was not idempotent.
|
||||
```
|
||||
|
||||
GREEN:
|
||||
|
||||
```text
|
||||
Test Files 1 passed (1)
|
||||
Tests 3 passed (3)
|
||||
```
|
||||
|
||||
### Single authorization check for positive bulk adjustment
|
||||
|
||||
RED:
|
||||
|
||||
```text
|
||||
pnpm exec vitest run --coverage.enabled=false src/actions/bulk-adjust-wrapper.test.ts
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed (1)
|
||||
Expected one requirePermission call; received two.
|
||||
```
|
||||
|
||||
GREEN:
|
||||
|
||||
```text
|
||||
Test Files 1 passed (1)
|
||||
Tests 1 passed (1)
|
||||
```
|
||||
|
||||
### Static gates during implementation
|
||||
|
||||
```text
|
||||
pnpm typecheck
|
||||
RED: one unused `describe` import in admin-ip.test.ts
|
||||
GREEN: tsc --noEmit, exit 0
|
||||
|
||||
pnpm exec biome check --formatter-enabled=false <exact Task 12 src files>
|
||||
RED: 14 import-order assists
|
||||
GREEN: checked 44 files, no fixes applied
|
||||
```
|
||||
|
||||
## Final verification
|
||||
|
||||
```text
|
||||
Focused wrapper/command/page/service/route/authorization/audit matrix
|
||||
Test Files 22 passed (22)
|
||||
Tests 129 passed (129)
|
||||
|
||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation
|
||||
Test Files 35 passed (35)
|
||||
Tests 381 passed (381)
|
||||
|
||||
pnpm test:housekeeping
|
||||
Test Files 54 passed (54)
|
||||
Tests 469 passed (469)
|
||||
|
||||
pnpm typecheck
|
||||
tsc --noEmit
|
||||
Exit 0
|
||||
|
||||
pnpm exec biome check --formatter-enabled=false <44 exact changed Task 12 src files>
|
||||
Checked 44 files. No fixes applied.
|
||||
|
||||
git diff --check
|
||||
Exit 0
|
||||
```
|
||||
|
||||
The Node engine warning remains the approved non-blocker: the repository requests Node `>=26.8.1 <27`, while this host runs Node `v26.7.0` with pnpm `11.24.0`. All test and type gates exited successfully.
|
||||
|
||||
No database operation, deployment, push, or pull-request update was performed.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,91 +1,50 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { disbandGuild } from "./admin-guilds";
|
||||
|
||||
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
|
||||
() => {
|
||||
const selectLimit = vi.fn();
|
||||
const transactionFn = vi.fn();
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) }));
|
||||
return { selectLimit, transactionFn, deleteWhere, updateSet };
|
||||
},
|
||||
);
|
||||
|
||||
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", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
transaction: transactionFn,
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
update: vi.fn(() => ({ set: updateSet })),
|
||||
},
|
||||
Guilds: { id: "id", name: "name", userId: "userId" },
|
||||
GuildsForumsThreads: { id: "id", guildId: "guildId" },
|
||||
GuildsForumsComments: { threadId: "threadId" },
|
||||
GuildForumViews: { guildId: "guildId" },
|
||||
GuildsMembers: { guildId: "guildId" },
|
||||
Rooms: { guildId: "guildId" },
|
||||
Items: { guildId: "guildId" },
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { USERS_EDIT: "admin.users.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
const form = (data: Record<string, string>) =>
|
||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: { id: 1 }, after: null },
|
||||
correlationId: "guild",
|
||||
});
|
||||
});
|
||||
|
||||
describe("disbandGuild", () => {
|
||||
it("disbands guild and cleans related data", async () => {
|
||||
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
|
||||
transactionFn.mockImplementation(
|
||||
async (fn: (tx: unknown) => Promise<void>) => {
|
||||
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
|
||||
const tx = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: txSelectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
|
||||
})),
|
||||
};
|
||||
// For threads findMany (no limit) — make where resolve to array
|
||||
tx.select = vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([{ id: 10 }]),
|
||||
})),
|
||||
}));
|
||||
await fn(tx);
|
||||
},
|
||||
);
|
||||
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
describe("disbandGuild legacy wrapper", () => {
|
||||
it("keeps rate-limited ACL, service input, and /admin revalidation", async () => {
|
||||
await disbandGuild(form({ id: "9" }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
|
||||
guildId: 9,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||
});
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
||||
expect(selectLimit).not.toHaveBeenCalled();
|
||||
it("keeps invalid IDs as a no-op", async () => {
|
||||
await disbandGuild(form({ id: "0" }));
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(revalidatePath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+20
-53
@@ -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");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
addBlacklist,
|
||||
@@ -9,80 +8,59 @@ import {
|
||||
deleteWhitelist,
|
||||
} from "./admin-ip";
|
||||
|
||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
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: { SETTINGS_EDIT: "settings.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteIpWhitelist: { id: "id" },
|
||||
WebsiteIpBlacklist: { id: "id" },
|
||||
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
const form = (data: Record<string, string>) =>
|
||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("addWhitelist", () => {
|
||||
it("creates whitelist entry", async () => {
|
||||
await addWhitelist(
|
||||
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
ipAddress: "192.168.1.1",
|
||||
asn: null,
|
||||
whitelistAsn: false,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
|
||||
});
|
||||
|
||||
it("returns early when ip is empty", async () => {
|
||||
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: null, after: {} },
|
||||
correlationId: "ip",
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWhitelist", () => {
|
||||
it("deletes whitelist entry", async () => {
|
||||
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
it("preserves all four IP actions and /admin revalidation", async () => {
|
||||
await addWhitelist(form({ ipAddress: "192.0.2.1", asn: "AS1" }));
|
||||
await addBlacklist(form({ ipAddress: "198.51.100.1" }));
|
||||
await deleteWhitelist(form({ id: "42" }));
|
||||
await deleteBlacklist(form({ id: "99" }));
|
||||
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
||||
[
|
||||
"ip.action",
|
||||
{ action: "add-whitelist", ipAddress: "192.0.2.1", asn: "AS1" },
|
||||
],
|
||||
[
|
||||
"ip.action",
|
||||
{ action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" },
|
||||
],
|
||||
["ip.action", { action: "delete-whitelist", id: 42 }],
|
||||
["ip.action", { action: "delete-blacklist", id: 99 }],
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
describe("addBlacklist", () => {
|
||||
it("creates blacklist entry", async () => {
|
||||
await addBlacklist(
|
||||
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
ipAddress: "203.0.113.1",
|
||||
asn: null,
|
||||
blacklistAsn: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBlacklist", () => {
|
||||
it("deletes blacklist entry", async () => {
|
||||
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
it("keeps empty IP input as a no-op after authorization", async () => {
|
||||
await addWhitelist(form({ ipAddress: "" }));
|
||||
expect(requirePermission).toHaveBeenCalledWith("admin.settings.edit");
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
+42
-51
@@ -1,72 +1,63 @@
|
||||
"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, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function parseIp(formData: FormData): string {
|
||||
return String(formData.get("ipAddress") ?? "")
|
||||
function parse(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
function parseAsn(formData: FormData): string | null {
|
||||
const asn = String(formData.get("asn") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return asn || null;
|
||||
async function run(
|
||||
formData: FormData,
|
||||
action:
|
||||
| "add-whitelist"
|
||||
| "delete-whitelist"
|
||||
| "add-blacklist"
|
||||
| "delete-blacklist",
|
||||
): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const adding = action.startsWith("add-");
|
||||
const input = adding
|
||||
? {
|
||||
action,
|
||||
ipAddress: parse(formData, "ipAddress"),
|
||||
asn: parse(formData, "asn"),
|
||||
}
|
||||
: { action, id: Number(formData.get("id")) };
|
||||
if (adding && !("ipAddress" in input && input.ipAddress)) return;
|
||||
const id = "id" in input ? input.id : undefined;
|
||||
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"ip.action",
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not update IP rules");
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await db.insert(WebsiteIpWhitelist).values({
|
||||
ipAddress,
|
||||
asn,
|
||||
whitelistAsn: asn != null,
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
return run(formData, "add-whitelist");
|
||||
}
|
||||
|
||||
export async function deleteWhitelist(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await db
|
||||
.delete(WebsiteIpWhitelist)
|
||||
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
|
||||
revalidatePath("/admin/ip");
|
||||
return run(formData, "delete-whitelist");
|
||||
}
|
||||
|
||||
export async function addBlacklist(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await db.insert(WebsiteIpBlacklist).values({
|
||||
ipAddress,
|
||||
asn,
|
||||
blacklistAsn: asn != null,
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
return run(formData, "add-blacklist");
|
||||
}
|
||||
|
||||
export async function deleteBlacklist(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await db
|
||||
.delete(WebsiteIpBlacklist)
|
||||
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
|
||||
revalidatePath("/admin/ip");
|
||||
return run(formData, "delete-blacklist");
|
||||
}
|
||||
@@ -1,59 +1,63 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { createTeam, deleteTeam } from "./admin-teams";
|
||||
|
||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
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: "users.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteTeams: { id: "id" },
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { USERS_EDIT: "admin.users.edit" },
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
const form = (data: Record<string, string>) =>
|
||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
});
|
||||
|
||||
describe("createTeam", () => {
|
||||
it("creates a team entry", async () => {
|
||||
await createTeam(
|
||||
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rankName: "Moderator" }),
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
|
||||
it("returns early when rankName is empty", async () => {
|
||||
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: null, after: {} },
|
||||
correlationId: "team",
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTeam", () => {
|
||||
it("deletes a team entry", async () => {
|
||||
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
});
|
||||
it("preserves create and delete team payloads plus /admin revalidation", async () => {
|
||||
await createTeam(form({ rankName: "Moderator" }));
|
||||
await deleteTeam(form({ id: "42" }));
|
||||
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
||||
[
|
||||
"team.change",
|
||||
{
|
||||
action: "create",
|
||||
rankName: "Moderator",
|
||||
badge: "",
|
||||
jobDescription: "",
|
||||
staffColor: "#327fa8",
|
||||
hiddenRank: false,
|
||||
},
|
||||
],
|
||||
["team.change", { action: "delete", teamId: 42 }],
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves empty rank name as a no-op", async () => {
|
||||
await createTeam(form({ rankName: "" }));
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
+45
-36
@@ -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");
|
||||
}
|
||||
@@ -1,60 +1,72 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { saveVpn } from "./admin-vpn";
|
||||
|
||||
const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => {
|
||||
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const mockValues = vi.fn(() => ({
|
||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||
}));
|
||||
return { mockValues, mockOnDuplicateKeyUpdate };
|
||||
});
|
||||
|
||||
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: { SETTINGS_EDIT: "settings.edit" },
|
||||
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: mockValues })),
|
||||
},
|
||||
WebsiteSetting: { key: "key", value: "value" },
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { reload: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
const form = (data: Record<string, string>) =>
|
||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
mockValues.mockReturnValue({
|
||||
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: {}, after: {} },
|
||||
correlationId: "vpn",
|
||||
});
|
||||
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("saveVpn", () => {
|
||||
it("saves VPN settings and redirects", async () => {
|
||||
await saveVpn(
|
||||
fakeForm({
|
||||
vpn_block_enabled: "1",
|
||||
vpn_provider: "proxycheck",
|
||||
vpn_api_key: "abc123",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(mockValues).toHaveBeenCalledTimes(4);
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
|
||||
expect(siteSettings.reload).toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||
it("preserves VPN payload, /admin revalidation, and redirect", async () => {
|
||||
await saveVpn(
|
||||
form({
|
||||
vpn_block_enabled: "1",
|
||||
vpn_provider: "proxycheck",
|
||||
vpn_api_key: "abc123",
|
||||
}),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "vpn.configure", {
|
||||
enabled: true,
|
||||
provider: "proxycheck",
|
||||
apiKey: "abc123",
|
||||
blockMessage: "",
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/vpn");
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||
});
|
||||
|
||||
it("preserves fail-soft redirect without claiming a saved revalidation", async () => {
|
||||
execute.mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||
},
|
||||
correlationId: "vpn-fail",
|
||||
});
|
||||
await saveVpn(form({ vpn_provider: "none" }));
|
||||
expect(revalidatePath).not.toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||
});
|
||||
+28
-72
@@ -2,88 +2,44 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
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, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// VPN / proxy detection config. Stored as website_settings key/value rows
|
||||
// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to
|
||||
// AtomCMS's setting() convention. This is registration-time protection only;
|
||||
// the raw IP allow/deny list lives under /admin/ip (website_ip_*).
|
||||
|
||||
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
|
||||
|
||||
/** Upsert one website_settings key with a stable housekeeping comment. */
|
||||
async function writeSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
comment: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
export async function saveVpn(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
|
||||
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||
const enabled =
|
||||
String(formData.get("vpn_block_enabled") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() !== "";
|
||||
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "")
|
||||
const rawProvider = String(formData.get("vpn_provider") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
|
||||
try {
|
||||
await writeSetting(
|
||||
"vpn_block_enabled",
|
||||
enabled ? "1" : "0",
|
||||
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_provider",
|
||||
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
"vpn.configure",
|
||||
{
|
||||
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
|
||||
provider,
|
||||
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_api_key",
|
||||
apiKey,
|
||||
"API key for the VPN/proxy detection provider",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_block_message",
|
||||
blockMessage,
|
||||
"Message shown to users blocked for using a VPN/proxy",
|
||||
);
|
||||
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "vpn_update",
|
||||
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
|
||||
});
|
||||
revalidatePath("/admin/vpn");
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw; the page
|
||||
// re-renders the current (stored) state.
|
||||
}
|
||||
|
||||
apiKey: String(formData.get("vpn_api_key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
blockMessage: String(formData.get("vpn_block_message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
},
|
||||
);
|
||||
if (result.ok) revalidatePath("/admin/vpn");
|
||||
// Preserve fail-soft legacy navigation even when persistence is unavailable.
|
||||
redirect("/admin/vpn?saved=1");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
+66
-150
@@ -1,95 +1,32 @@
|
||||
// @ts-nocheck
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import {
|
||||
bulkBan,
|
||||
bulkGiveBadge,
|
||||
bulkGiveCurrency,
|
||||
bulkUnban,
|
||||
setTradeLock,
|
||||
} from "./bulk-users";
|
||||
|
||||
const {
|
||||
deleteWhere,
|
||||
insertValues,
|
||||
updateWhere,
|
||||
selectLimit,
|
||||
selectWhereResolved,
|
||||
onDuplicateKeyUpdate,
|
||||
} = vi.hoisted(() => {
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 3 }]);
|
||||
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const insertValues = vi.fn(() => ({
|
||||
onDuplicateKeyUpdate,
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
|
||||
then(resolve, reject) {
|
||||
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
|
||||
},
|
||||
}));
|
||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const selectLimit = vi.fn().mockResolvedValue([]);
|
||||
/** Rows returned when a select chain is awaited without `.limit()`. */
|
||||
const selectWhereResolved = vi.fn().mockResolvedValue([]);
|
||||
return {
|
||||
deleteWhere,
|
||||
insertValues,
|
||||
updateWhere,
|
||||
selectLimit,
|
||||
selectWhereResolved,
|
||||
onDuplicateKeyUpdate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: updateWhere })),
|
||||
})),
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
|
||||
then(resolve, reject) {
|
||||
return selectWhereResolved().then(resolve, reject);
|
||||
},
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
transaction: vi.fn(),
|
||||
},
|
||||
Ban: { userId: "userId", id: "id" },
|
||||
User: {
|
||||
id: "id",
|
||||
credits: "credits",
|
||||
username: "username",
|
||||
online: "online",
|
||||
},
|
||||
UsersCurrency: { userId: "userId", type: "type", amount: "amount" },
|
||||
UsersBadges: {
|
||||
id: "id",
|
||||
userId: "userId",
|
||||
badgeCode: "badgeCode",
|
||||
slotId: "slotId",
|
||||
},
|
||||
Sanctions: { id: "id", habboId: "habboId" },
|
||||
UsersSettings: {
|
||||
userId: "userId",
|
||||
canTrade: "canTrade",
|
||||
tradelockAmount: "tradelockAmount",
|
||||
},
|
||||
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/services/rcon", () => ({
|
||||
rcon: {
|
||||
giveCredits: vi.fn(),
|
||||
giveDuckets: vi.fn(),
|
||||
givePointsGotw: vi.fn(),
|
||||
giveBadge: vi.fn(),
|
||||
},
|
||||
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() }));
|
||||
|
||||
@@ -98,83 +35,62 @@ const staff = { id: 1, rank: 7, username: "admin" };
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 3 }]);
|
||||
insertValues.mockImplementation(() => ({
|
||||
onDuplicateKeyUpdate,
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
|
||||
then(resolve, reject) {
|
||||
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
|
||||
execute.mockImplementation(async (context, operation, input) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { input },
|
||||
after: {
|
||||
completed: operation === "users.bulk-unban" ? 3 : 2,
|
||||
total: Array.isArray(input.userIds) ? input.userIds.length : 1,
|
||||
failedIds: [],
|
||||
},
|
||||
output: operation === "user.trade-lock" ? input : undefined,
|
||||
},
|
||||
correlationId: context.correlationId,
|
||||
}));
|
||||
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
selectLimit.mockResolvedValue([]);
|
||||
selectWhereResolved.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("bulkUnban", () => {
|
||||
it("unbans users", async () => {
|
||||
const r = await bulkUnban({ userIds: [1, 2, 3] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.data).toEqual({ unbanned: 3, total: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkBan", () => {
|
||||
it("bans users", async () => {
|
||||
const r = await bulkBan({
|
||||
userIds: [1, 2],
|
||||
reason: "Spam",
|
||||
duration: 3600,
|
||||
describe("legacy bulk user wrappers", () => {
|
||||
it("preserves result shapes while delegating the exact operations", async () => {
|
||||
await expect(bulkUnban({ userIds: [1, 2, 3] })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { unbanned: 3, total: 3 },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.data.banned).toBe(2);
|
||||
expect(insertValues).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkGiveCurrency", () => {
|
||||
it("gives credits", async () => {
|
||||
const r = await bulkGiveCurrency({
|
||||
userIds: [1],
|
||||
amount: 100,
|
||||
type: "credits",
|
||||
await expect(
|
||||
bulkBan({ userIds: [1, 2], reason: "Spam", duration: 3600 }),
|
||||
).resolves.toEqual({ ok: true, data: { banned: 2 } });
|
||||
await expect(
|
||||
bulkGiveCurrency({ userIds: [1], amount: 100, type: "credits" }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { given: 2, total: 1, failedIds: [] },
|
||||
});
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives pixels", async () => {
|
||||
const r = await bulkGiveCurrency({
|
||||
userIds: [2],
|
||||
amount: 50,
|
||||
type: "pixels",
|
||||
await expect(
|
||||
bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { given: 2, total: 1, failedIds: [] },
|
||||
});
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
|
||||
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives points", async () => {
|
||||
const r = await bulkGiveCurrency({
|
||||
userIds: [3],
|
||||
amount: 25,
|
||||
type: "points",
|
||||
});
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
|
||||
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
|
||||
expect(execute.mock.calls.map((call) => call[1])).toEqual([
|
||||
"users.bulk-unban",
|
||||
"users.bulk-ban",
|
||||
"users.bulk-currency",
|
||||
"users.bulk-badge",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkGiveBadge", () => {
|
||||
it("gives badge to user", async () => {
|
||||
selectLimit.mockResolvedValueOnce([]);
|
||||
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
|
||||
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
|
||||
expect(r.data.given).toBe(1);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
|
||||
it("preserves the trade-lock API and exact normalized payload", async () => {
|
||||
await expect(
|
||||
setTradeLock({ userId: 9, untilUnix: 1234.8 }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { userId: 9, untilUnix: 1234 },
|
||||
});
|
||||
expect(execute).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ permission: "admin.users.edit" }),
|
||||
"user.trade-lock",
|
||||
{ userId: 9, untilUnix: 1234 },
|
||||
);
|
||||
});
|
||||
});
|
||||
+114
-225
@@ -1,40 +1,75 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
Sanctions,
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
createLegacyPeopleMutationContext,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, User, UsersCurrency } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
async function executeLegacy(
|
||||
staff: {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly rank: number;
|
||||
},
|
||||
operation:
|
||||
| "users.bulk-ban"
|
||||
| "users.bulk-unban"
|
||||
| "users.bulk-currency"
|
||||
| "users.bulk-badge"
|
||||
| "user.trade-lock",
|
||||
input: unknown,
|
||||
) {
|
||||
return peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
staff,
|
||||
PERMS.USERS_EDIT,
|
||||
createCorrelationId(),
|
||||
),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
return Number.isSafeInteger(Number(value)) ? Number(value) : 0;
|
||||
}
|
||||
|
||||
function failedIds(value: unknown): Array<{ userId: number; reason: string }> {
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((item) =>
|
||||
typeof item === "object" && item !== null
|
||||
? [
|
||||
{
|
||||
userId: numberValue(Reflect.get(item, "userId")),
|
||||
reason: String(Reflect.get(item, "reason") ?? "Database error"),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function bulkUnban({
|
||||
userIds,
|
||||
}: {
|
||||
userIds: number[];
|
||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
|
||||
const unbanned = Number(
|
||||
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||
);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_unban",
|
||||
description: `Unbanned ${unbanned} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
const result = await executeLegacy(staff, "users.bulk-unban", { userIds });
|
||||
if (!result.ok) return { ok: false, error: "Bulk unban failed" };
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { unbanned, total: userIds.length },
|
||||
ok: true,
|
||||
data: {
|
||||
unbanned: numberValue(result.data.after?.completed),
|
||||
total: numberValue(result.data.after?.total),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,34 +83,16 @@ export async function bulkBan({
|
||||
duration: number;
|
||||
}): Promise<ActionResult<{ banned: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let banned = 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire: duration > 0 ? now + duration : 0,
|
||||
banReason: reason,
|
||||
type: "account",
|
||||
});
|
||||
banned++;
|
||||
} catch {
|
||||
// skip duplicates
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_ban",
|
||||
description: `Banned ${banned} user(s)`,
|
||||
targetType: "user",
|
||||
const result = await executeLegacy(staff, "users.bulk-ban", {
|
||||
userIds,
|
||||
reason,
|
||||
duration,
|
||||
});
|
||||
return { ok: true as const, data: { banned } };
|
||||
if (!result.ok) return { ok: false, error: "Bulk ban failed" };
|
||||
return {
|
||||
ok: true,
|
||||
data: { banned: numberValue(result.data.after?.completed) },
|
||||
};
|
||||
}
|
||||
|
||||
export async function bulkGiveCurrency({
|
||||
@@ -94,49 +111,19 @@ export async function bulkGiveCurrency({
|
||||
}>
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||
.where(eq(User.id, userId));
|
||||
await rcon.giveCredits(userId, amount);
|
||||
} else if (type === "pixels") {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: 0, amount })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} else if (type === "points") {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: 101, amount })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
await rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_give_currency",
|
||||
description: `Gave ${amount} ${type} to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
const result = await executeLegacy(staff, "users.bulk-currency", {
|
||||
userIds,
|
||||
amount,
|
||||
type,
|
||||
});
|
||||
if (!result.ok) return { ok: false, error: "Bulk currency failed" };
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { given, total: userIds.length, failedIds },
|
||||
ok: true,
|
||||
data: {
|
||||
given: numberValue(result.data.after?.completed),
|
||||
total: numberValue(result.data.after?.total),
|
||||
failedIds: failedIds(result.data.after?.failedIds),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,45 +141,18 @@ export async function bulkGiveBadge({
|
||||
}>
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(
|
||||
eq(UsersBadges.userId, userId),
|
||||
eq(UsersBadges.badgeCode, badgeCode),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
const [agg] = await db
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_give_badge",
|
||||
description: `Gave badge "${badgeCode}" to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
const result = await executeLegacy(staff, "users.bulk-badge", {
|
||||
userIds,
|
||||
badgeCode,
|
||||
});
|
||||
if (!result.ok) return { ok: false, error: "Bulk badge failed" };
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { given, total: userIds.length, failedIds },
|
||||
ok: true,
|
||||
data: {
|
||||
given: numberValue(result.data.after?.completed),
|
||||
total: numberValue(result.data.after?.total),
|
||||
failedIds: failedIds(result.data.after?.failedIds),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,7 +162,6 @@ export async function bulkAdjustCurrency({
|
||||
type,
|
||||
}: {
|
||||
userIds: number[];
|
||||
/** Positive = give, negative = take. Balances clamped at 0. */
|
||||
amount: number;
|
||||
type: "credits" | "pixels" | "points";
|
||||
}): Promise<
|
||||
@@ -214,29 +173,28 @@ export async function bulkAdjustCurrency({
|
||||
> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
if (!Number.isFinite(amount) || amount === 0) {
|
||||
return { ok: false as const, error: "Amount must be a non-zero number" };
|
||||
return { ok: false, error: "Amount must be a non-zero number" };
|
||||
}
|
||||
|
||||
if (amount > 0) {
|
||||
const given = await bulkGiveCurrency({ userIds, amount, type });
|
||||
if (!given.ok) return given;
|
||||
if (!given.data) {
|
||||
return { ok: false as const, error: "Currency adjustment failed" };
|
||||
}
|
||||
const result = await executeLegacy(staff, "users.bulk-currency", {
|
||||
userIds,
|
||||
amount,
|
||||
type,
|
||||
});
|
||||
if (!result.ok) return { ok: false, error: "Currency adjustment failed" };
|
||||
return {
|
||||
ok: true as const,
|
||||
ok: true,
|
||||
data: {
|
||||
adjusted: given.data.given,
|
||||
total: given.data.total,
|
||||
failedIds: given.data.failedIds,
|
||||
adjusted: numberValue(result.data.after?.completed),
|
||||
total: numberValue(result.data.after?.total),
|
||||
failedIds: failedIds(result.data.after?.failedIds),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const take = Math.abs(Math.trunc(amount));
|
||||
let adjusted = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
const failures: Array<{ userId: number; reason: string }> = [];
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
@@ -246,11 +204,13 @@ export async function bulkAdjustCurrency({
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!user) {
|
||||
failedIds.push({ userId, reason: "Not found" });
|
||||
failures.push({ userId, reason: "Not found" });
|
||||
continue;
|
||||
}
|
||||
const next = Math.max(0, user.credits - take);
|
||||
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: Math.max(0, user.credits - take) })
|
||||
.where(eq(User.id, userId));
|
||||
} else {
|
||||
const currencyType = type === "pixels" ? 0 : 101;
|
||||
const [row] = await db
|
||||
@@ -263,19 +223,17 @@ export async function bulkAdjustCurrency({
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const current = row?.amount ?? 0;
|
||||
const next = Math.max(0, current - take);
|
||||
const next = Math.max(0, (row?.amount ?? 0) - take);
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: currencyType, amount: next })
|
||||
.onDuplicateKeyUpdate({ set: { amount: next } });
|
||||
}
|
||||
adjusted++;
|
||||
adjusted += 1;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_adjust_currency",
|
||||
@@ -283,93 +241,24 @@ export async function bulkAdjustCurrency({
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { adjusted, total: userIds.length, failedIds },
|
||||
ok: true,
|
||||
data: { adjusted, total: userIds.length, failedIds: failures },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade`
|
||||
* via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online).
|
||||
*/
|
||||
export async function setTradeLock({
|
||||
userId,
|
||||
untilUnix,
|
||||
}: {
|
||||
userId: number;
|
||||
/** Unix seconds; 0 clears the lock. */
|
||||
untilUnix: number;
|
||||
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const until = Math.max(0, Math.trunc(untilUnix));
|
||||
const locked = until > 0;
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!user) {
|
||||
return { ok: false as const, error: "User not found" };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: Sanctions.id })
|
||||
.from(Sanctions)
|
||||
.where(eq(Sanctions.habboId, userId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(Sanctions)
|
||||
.set({
|
||||
tradeLockedUntil: until,
|
||||
...(locked ? { reason: "Trade lock (CMS)" } : {}),
|
||||
})
|
||||
.where(eq(Sanctions.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(Sanctions).values({
|
||||
habboId: userId,
|
||||
tradeLockedUntil: until,
|
||||
reason: locked ? "Trade lock (CMS)" : "",
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(UsersSettings)
|
||||
.set({
|
||||
canTrade: locked ? "0" : "1",
|
||||
...(locked
|
||||
? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(UsersSettings.userId, userId));
|
||||
});
|
||||
|
||||
await rcon.setTradeLock(userId, locked);
|
||||
await rcon.alertUser(
|
||||
const result = await executeLegacy(staff, "user.trade-lock", {
|
||||
userId,
|
||||
locked
|
||||
? "Trading has been disabled by staff."
|
||||
: "Trading has been re-enabled by staff.",
|
||||
);
|
||||
if (user.online === "1") {
|
||||
await rcon.disconnectUser(userId, user.username);
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: locked ? "trade_lock" : "trade_unlock",
|
||||
description: locked
|
||||
? `Trade-locked ${user.username} (#${userId}) until ${until}`
|
||||
: `Cleared trade lock for ${user.username} (#${userId})`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
untilUnix: until,
|
||||
});
|
||||
|
||||
return { ok: true as const, data: { userId, untilUnix: until } };
|
||||
if (!result.ok) return { ok: false, error: "Trade lock update failed" };
|
||||
return { ok: true, data: { userId, untilUnix: until } };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
|
||||
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() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: {
|
||||
USERS_EDIT: "admin.users.edit",
|
||||
USERS_BAN: "admin.users.ban",
|
||||
USERS_RESET_PASSWORD: "admin.users.reset_password",
|
||||
WORDFILTER_EDIT: "admin.wordfilter.edit",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction:
|
||||
(_options: unknown, handler: (context: unknown) => unknown) =>
|
||||
(data: unknown) =>
|
||||
handler({ data, session: { user: staff } }),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
ActionError: class ActionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ActionError";
|
||||
}
|
||||
},
|
||||
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||
actionError: (error: string) => ({ ok: false, error }),
|
||||
}));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {},
|
||||
User: {},
|
||||
UsersBadges: {},
|
||||
UsersCurrency: {},
|
||||
UsersSettings: {},
|
||||
}));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: {} }));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { dismissApplication } from "./admin-applications";
|
||||
import { addWord, deleteWord } from "./admin-wordfilter";
|
||||
import { banUser, resetPassword, updateUser } from "./users";
|
||||
|
||||
const form = (data: Record<string, string>) =>
|
||||
({ get: (key: string) => data[key] ?? null }) as FormData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff);
|
||||
execute.mockImplementation(async (context, operation) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
before: {},
|
||||
after: operation === "word-filter.update" ? { id: 12 } : {},
|
||||
output:
|
||||
operation === "user.reset-password"
|
||||
? { newPassword: "temporary-password" }
|
||||
: undefined,
|
||||
},
|
||||
correlationId: context.correlationId,
|
||||
}));
|
||||
});
|
||||
|
||||
describe("legacy user safe-action wrappers", () => {
|
||||
it("preserves exact ACL-specific service delegation", async () => {
|
||||
await (updateUser as never as (input: unknown) => Promise<unknown>)({
|
||||
id: 7,
|
||||
motto: "Ready",
|
||||
});
|
||||
await (banUser as never as (input: unknown) => Promise<unknown>)({
|
||||
userId: 7,
|
||||
reason: "abuse",
|
||||
duration: 0,
|
||||
type: "account",
|
||||
});
|
||||
const reset = await (
|
||||
resetPassword as never as (input: unknown) => Promise<{
|
||||
ok: boolean;
|
||||
data: { newPassword: string };
|
||||
}>
|
||||
)({ userId: 7 });
|
||||
|
||||
expect(
|
||||
execute.mock.calls.map((call) => [call[0].permission, call[1]]),
|
||||
).toEqual([
|
||||
["admin.users.edit", "user.update"],
|
||||
["admin.users.ban", "user.ban"],
|
||||
["admin.users.reset_password", "user.reset-password"],
|
||||
]);
|
||||
expect(reset.data.newPassword).toBe("temporary-password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy application and word-filter wrappers", () => {
|
||||
it("keeps tolerant application dismissal and /admin revalidation", async () => {
|
||||
await dismissApplication(form({ id: "9" }));
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ permission: "admin.users.edit" }),
|
||||
"application.decide",
|
||||
{ applicationId: 9, decision: "dismiss" },
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||
});
|
||||
|
||||
it("preserves word-filter ActionResult shapes and /admin revalidation", async () => {
|
||||
await expect(addWord({ word: "spam" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { id: "12" },
|
||||
});
|
||||
await expect(deleteWord({ id: "12" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
|
||||
{ action: "add", word: "spam" },
|
||||
{ action: "delete", id: 12 },
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,18 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { invalidateLoginCache } from "@/lib/auth";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
createLegacyPeopleMutationContext,
|
||||
type PeopleMutationOperation,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { db, User, UsersBadges, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
@@ -31,22 +28,23 @@ const DEFAULT_LOOK =
|
||||
|
||||
function isDuplicateKey(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const e = err as { code?: string | number; errno?: number };
|
||||
return e.code === "P2002" || e.code === "ER_DUP_ENTRY" || e.errno === 1062;
|
||||
const error = err as { code?: string | number; errno?: number };
|
||||
return (
|
||||
error.code === "P2002" ||
|
||||
error.code === "ER_DUP_ENTRY" ||
|
||||
error.errno === 1062
|
||||
);
|
||||
}
|
||||
|
||||
function duplicateField(err: unknown): "username" | "mail" | null {
|
||||
if (!isDuplicateKey(err)) return null;
|
||||
const e = err as {
|
||||
message?: string;
|
||||
meta?: { target?: string[] };
|
||||
};
|
||||
const target = e.meta?.target ?? [];
|
||||
const error = err as { message?: string; meta?: { target?: string[] } };
|
||||
const target = error.meta?.target ?? [];
|
||||
if (target.includes("username")) return "username";
|
||||
if (target.includes("mail")) return "mail";
|
||||
const msg = e.message ?? "";
|
||||
if (msg.includes("username")) return "username";
|
||||
if (msg.includes("mail")) return "mail";
|
||||
const message = error.message ?? "";
|
||||
if (message.includes("username")) return "username";
|
||||
if (message.includes("mail")) return "mail";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -54,14 +52,11 @@ export const createUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
||||
async (ctx) => {
|
||||
const { username, mail, password, rank, motto } = ctx.data;
|
||||
|
||||
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
const user = await db.transaction(async (tx) => {
|
||||
const [result] = await tx.insert(User).values({
|
||||
@@ -78,44 +73,73 @@ export const createUser = adminAction(
|
||||
ipCurrent: "0.0.0.0",
|
||||
});
|
||||
const id = Number(result.insertId);
|
||||
|
||||
await tx.insert(UsersSettings).values({ userId: id });
|
||||
await tx.insert(UsersCurrency).values([
|
||||
{ userId: id, type: 0, amount: 5000 },
|
||||
{ userId: id, type: 5, amount: 5000 },
|
||||
]);
|
||||
|
||||
return { id, username };
|
||||
});
|
||||
|
||||
logAudit({
|
||||
void logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_create",
|
||||
target: "User",
|
||||
targetId: user.id,
|
||||
after: { username, mail, rank },
|
||||
});
|
||||
|
||||
notify({
|
||||
void notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: username,
|
||||
targetId: user.id,
|
||||
details: "Account created by admin",
|
||||
});
|
||||
|
||||
return actionOk({ id: user.id, username: user.username });
|
||||
} catch (err) {
|
||||
const field = duplicateField(err);
|
||||
return actionOk(user);
|
||||
} catch (error) {
|
||||
const field = duplicateField(error);
|
||||
if (field === "username") throw new ActionError("Username already taken");
|
||||
if (field === "mail") throw new ActionError("Email already registered");
|
||||
if (isDuplicateKey(err))
|
||||
if (isDuplicateKey(error))
|
||||
throw new ActionError("Username or email already in use");
|
||||
throw err;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
|
||||
"user.alert": "Failed to send alert. Is the emulator running?",
|
||||
"user.disconnect": "Failed to disconnect. Is the emulator running?",
|
||||
"user.mute": "Failed to mute. Is the emulator running?",
|
||||
"user.unmute": "Failed to unmute. Is the emulator running?",
|
||||
"user.send-currency": "Failed to send credits. Is the emulator running?",
|
||||
};
|
||||
|
||||
async function executeLegacy(
|
||||
ctx: { session: { user: { id: number; username: string; rank: number } } },
|
||||
permission: string,
|
||||
operation: PeopleMutationOperation,
|
||||
input: unknown,
|
||||
) {
|
||||
const result = await peopleMutationService.execute(
|
||||
createLegacyPeopleMutationContext(
|
||||
ctx.session.user,
|
||||
permission,
|
||||
createCorrelationId(),
|
||||
),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (result.error.code === "NOT_FOUND")
|
||||
throw new ActionError("User not found");
|
||||
if (result.error.code === "FORBIDDEN") {
|
||||
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||
}
|
||||
throw new ActionError(legacyMessages[operation] ?? "User action failed");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const updateUserInput = updateUserSchema.extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
@@ -123,149 +147,115 @@ const updateUserInput = updateUserSchema.extend({
|
||||
export const updateUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||
async (ctx) => {
|
||||
const { id, diamonds, duckets, ...userData } = ctx.data;
|
||||
|
||||
const targetUser = await guardRank(id, ctx.session.user.rank);
|
||||
|
||||
if (
|
||||
userData.rank !== undefined &&
|
||||
userData.rank >= ctx.session.user.rank &&
|
||||
ctx.session.user.rank < 7
|
||||
) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(userData).filter(([, v]) => v !== undefined),
|
||||
) as Partial<{
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
}>;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await db.update(User).set(patch).where(eq(User.id, id));
|
||||
}
|
||||
invalidateLoginCache(targetUser.username);
|
||||
|
||||
if (diamonds !== undefined) {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId: id, type: 5, amount: diamonds })
|
||||
.onDuplicateKeyUpdate({ set: { amount: diamonds } });
|
||||
}
|
||||
if (duckets !== undefined) {
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId: id, type: 0, amount: duckets })
|
||||
.onDuplicateKeyUpdate({ set: { amount: duckets } });
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_edit",
|
||||
target: "User",
|
||||
targetId: id,
|
||||
before: {
|
||||
username: targetUser.username,
|
||||
mail: targetUser.mail,
|
||||
rank: targetUser.rank,
|
||||
},
|
||||
after: userData,
|
||||
const { id: userId, ...fields } = ctx.data;
|
||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.update", {
|
||||
userId,
|
||||
fields,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const banInput = banUserSchema.extend({});
|
||||
|
||||
export const banUser = adminAction(
|
||||
{ permission: PERMS.USERS_BAN, schema: banInput },
|
||||
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
|
||||
async (ctx) => {
|
||||
const { userId, reason, duration, type, ip } = ctx.data;
|
||||
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire = duration > 0 ? now + duration * 3600 : 0;
|
||||
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
userStaffId: ctx.session.user.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type || "account",
|
||||
ip: ip || "",
|
||||
machineId: "",
|
||||
});
|
||||
|
||||
await rcon.disconnectUser(userId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
after: { reason, type, duration },
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "ban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
details: reason,
|
||||
});
|
||||
|
||||
await executeLegacy(ctx, PERMS.USERS_BAN, "user.ban", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
|
||||
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||
|
||||
export const unbanUser = adminAction(
|
||||
{ permission: PERMS.USERS_BAN, schema: unbanInput },
|
||||
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
const { userId } = ctx.data;
|
||||
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
await db.delete(Ban).where(eq(Ban.userId, userId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "unban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "unban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
});
|
||||
|
||||
await executeLegacy(ctx, PERMS.USERS_BAN, "user.unban", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const resetPassword = adminAction(
|
||||
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
const snapshot = await executeLegacy(
|
||||
ctx,
|
||||
PERMS.USERS_RESET_PASSWORD,
|
||||
"user.reset-password",
|
||||
ctx.data,
|
||||
);
|
||||
return actionOk({
|
||||
newPassword: String(snapshot.output?.newPassword ?? ""),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.disconnect", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const alertUserSchema = userIdSchema.extend({
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.alert", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const muteSchema = userIdSchema.extend({
|
||||
duration: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
export const muteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.mute", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const unmuteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.unmute", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const sendCreditsSchema = userIdSchema.extend({
|
||||
amount: z.coerce.number().int().min(1).max(1_000_000),
|
||||
});
|
||||
export const sendCredits = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||
async (ctx) => {
|
||||
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.send-currency", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
async function guardRank(targetUserId: number, sessionRank: number) {
|
||||
const [target] = await db
|
||||
.select({ username: User.username, rank: User.rank, mail: User.mail })
|
||||
.from(User)
|
||||
.where(eq(User.id, targetUserId))
|
||||
.limit(1);
|
||||
if (!target) throw new ActionError("User not found");
|
||||
if (target.rank >= sessionRank && sessionRank < 7) {
|
||||
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
@@ -277,28 +267,21 @@ export const giveBadge = adminAction(
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) throw new ActionError("Badge already assigned");
|
||||
|
||||
await db.insert(UsersBadges).values({ userId, badgeCode });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Remove Badge ────────────────────────────────────────────────────
|
||||
|
||||
const removeBadgeSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
badgeCode: z.string().min(1),
|
||||
});
|
||||
|
||||
export const removeBadge = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
@@ -310,199 +293,8 @@ export const removeBadge = adminAction(
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Badge not found");
|
||||
|
||||
await db.delete(UsersBadges).where(eq(UsersBadges.id, existing.id));
|
||||
await rcon.removeBadge(userId, badgeCode);
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Rank guard helper ───────────────────────────────────────────────
|
||||
|
||||
async function guardRank(targetUserId: number, sessionRank: number) {
|
||||
const [target] = await db
|
||||
.select({
|
||||
username: User.username,
|
||||
rank: User.rank,
|
||||
mail: User.mail,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, targetUserId))
|
||||
.limit(1);
|
||||
if (!target) throw new ActionError("User not found");
|
||||
if (target.rank >= sessionRank && sessionRank < 7) {
|
||||
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
// ── Reset Password ──────────────────────────────────────────────────
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const resetPassword = adminAction(
|
||||
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
|
||||
const newPassword = crypto
|
||||
.randomBytes(12)
|
||||
.toString("base64url")
|
||||
.slice(0, 16);
|
||||
const hashed = await hashPassword(newPassword);
|
||||
|
||||
await db
|
||||
.update(User)
|
||||
.set({ password: hashed })
|
||||
.where(eq(User.id, ctx.data.userId));
|
||||
invalidateLoginCache(target.username);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "reset_password",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
|
||||
return actionOk({ newPassword });
|
||||
},
|
||||
);
|
||||
|
||||
// ── Disconnect User ─────────────────────────────────────────────────
|
||||
|
||||
const disconnectSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.disconnectUser(ctx.data.userId);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to disconnect. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_disconnect",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "disconnect",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Alert User (in-game message) ────────────────────────────────────
|
||||
|
||||
const alertUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to send alert. Is the emulator running?");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Mute User ───────────────────────────────────────────────────────
|
||||
|
||||
const muteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const muteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
void _target;
|
||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to mute. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_mute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Unmute User ─────────────────────────────────────────────────────
|
||||
|
||||
const unmuteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const unmuteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
||||
async (ctx) => {
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.unmuteUser(ctx.data.userId);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to unmute. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_unmute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Send Credits via RCON ───────────────────────────────────────────
|
||||
|
||||
const sendCreditsSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
amount: z.coerce.number().int().min(1).max(1000000),
|
||||
});
|
||||
|
||||
export const sendCredits = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
void _target;
|
||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_send_credits",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { amount: ctx.data.amount },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
import {
|
||||
COMMUNITY_COMMAND_IDS,
|
||||
COMMUNITY_COMMANDS,
|
||||
createCommunityCommands,
|
||||
} from "./community-commands";
|
||||
|
||||
const expectedCommunityCommandIds = [
|
||||
"people.guild.disband",
|
||||
"people.application.decide",
|
||||
"people.team.change",
|
||||
"people.ip.action",
|
||||
"people.vpn.configure",
|
||||
"people.word-filter.update",
|
||||
] as const;
|
||||
|
||||
const commands = COMMUNITY_COMMANDS as unknown as readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("People community and staff commands", () => {
|
||||
it("declares the exact stable People-owned command IDs", () => {
|
||||
expect(COMMUNITY_COMMAND_IDS).toEqual(expectedCommunityCommandIds);
|
||||
expect(commands.map((command) => command.id)).toEqual(
|
||||
expectedCommunityCommandIds,
|
||||
);
|
||||
expect(commands.every((command) => command.owner === "people")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the exact legacy ACL boundary", () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
commands.map((command) => [command.id, command.capability.slugs]),
|
||||
),
|
||||
).toEqual({
|
||||
"people.guild.disband": [PERMS.USERS_EDIT],
|
||||
"people.application.decide": [PERMS.USERS_EDIT],
|
||||
"people.team.change": [PERMS.USERS_EDIT],
|
||||
"people.ip.action": [PERMS.SETTINGS_EDIT],
|
||||
"people.vpn.configure": [PERMS.SETTINGS_EDIT],
|
||||
"people.word-filter.update": [PERMS.WORDFILTER_EDIT],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a nonblank reason for every destructive, global, or security mutation", () => {
|
||||
expect(commands.every((command) => command.requiresReason)).toBe(true);
|
||||
for (const command of commands) {
|
||||
expect(
|
||||
confirmHousekeepingCommand(command, "\t", "community-reason"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "VALIDATION" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["people.guild.disband", { guildId: 0 }],
|
||||
["people.application.decide", { applicationId: 7, decision: "accept" }],
|
||||
["people.team.change", { action: "create", rankName: " " }],
|
||||
["people.ip.action", { action: "add-whitelist", ipAddress: " " }],
|
||||
[
|
||||
"people.vpn.configure",
|
||||
{ enabled: true, provider: "unknown", apiKey: "", blockMessage: "" },
|
||||
],
|
||||
["people.word-filter.update", { action: "add", word: " " }],
|
||||
] as const)(
|
||||
"rejects an invalid bounded payload for %s",
|
||||
(commandId, input) => {
|
||||
const command = commands.find((entry) => entry.id === commandId);
|
||||
if (!command) throw new Error(`command missing: ${commandId}`);
|
||||
expect(command.input.safeParse(input).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("delegates the canonical operation without a redirect", async () => {
|
||||
const execute = vi.fn(async (context, operation, input) => ({
|
||||
ok: true as const,
|
||||
data: { before: { id: 9 }, after: null, operation, input },
|
||||
correlationId: context.correlationId,
|
||||
}));
|
||||
const created = createCommunityCommands({
|
||||
execute,
|
||||
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
||||
const command = created.find(
|
||||
(entry) => entry.id === "people.guild.disband",
|
||||
);
|
||||
if (!command) throw new Error("command missing");
|
||||
const input = command.input.parse({ guildId: 9 });
|
||||
await command.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "guild-command",
|
||||
ipAddress: "198.51.100.8",
|
||||
},
|
||||
input,
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ correlationId: "guild-command" }),
|
||||
"guild.disband",
|
||||
{ guildId: 9 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type PeopleMutationOperation,
|
||||
type PeopleMutationService,
|
||||
peopleMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
export const COMMUNITY_COMMAND_IDS = [
|
||||
"people.guild.disband",
|
||||
"people.application.decide",
|
||||
"people.team.change",
|
||||
"people.ip.action",
|
||||
"people.vpn.configure",
|
||||
"people.word-filter.update",
|
||||
] as const;
|
||||
|
||||
export type CommunityCommandId = (typeof COMMUNITY_COMMAND_IDS)[number];
|
||||
|
||||
interface CommandOptions<I> {
|
||||
readonly id: CommunityCommandId;
|
||||
readonly operation: PeopleMutationOperation;
|
||||
readonly capability: string;
|
||||
readonly input: z.ZodType<I>;
|
||||
}
|
||||
|
||||
function communityCommand<I>(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
options: CommandOptions<I>,
|
||||
): HousekeepingCommand<I, unknown> {
|
||||
return {
|
||||
id: options.id,
|
||||
owner: "people",
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(options.capability),
|
||||
input: options.input,
|
||||
requiresReason: true,
|
||||
rateLimit: { attempts: 5, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
capability: context.capability,
|
||||
correlationId: context.correlationId,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const positiveId = z.number().int().positive();
|
||||
const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||
|
||||
export function createCommunityCommands(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
) {
|
||||
return [
|
||||
communityCommand(service, {
|
||||
id: "people.guild.disband",
|
||||
operation: "guild.disband",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ guildId: positiveId }),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.application.decide",
|
||||
operation: "application.decide",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
applicationId: positiveId,
|
||||
decision: z.literal("dismiss"),
|
||||
}),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.team.change",
|
||||
operation: "team.change",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("create"),
|
||||
rankName: requiredText(255),
|
||||
badge: z.string().max(255).optional(),
|
||||
jobDescription: z.string().max(255).optional(),
|
||||
staffColor: z.string().min(1).max(255).optional(),
|
||||
hiddenRank: z.boolean().optional(),
|
||||
}),
|
||||
z.object({ action: z.literal("delete"), teamId: positiveId }),
|
||||
]),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.ip.action",
|
||||
operation: "ip.action",
|
||||
capability: PERMS.SETTINGS_EDIT,
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.enum(["add-whitelist", "add-blacklist"]),
|
||||
ipAddress: requiredText(255),
|
||||
asn: z.string().max(255).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["delete-whitelist", "delete-blacklist"]),
|
||||
id: positiveId,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.vpn.configure",
|
||||
operation: "vpn.configure",
|
||||
capability: PERMS.SETTINGS_EDIT,
|
||||
input: z.object({
|
||||
enabled: z.boolean(),
|
||||
provider: z.enum(["none", "proxycheck", "ipqualityscore"]),
|
||||
apiKey: z.string().max(255),
|
||||
blockMessage: z.string().max(255),
|
||||
}),
|
||||
}),
|
||||
communityCommand(service, {
|
||||
id: "people.word-filter.update",
|
||||
operation: "word-filter.update",
|
||||
capability: PERMS.WORDFILTER_EDIT,
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("add"), word: requiredText(255) }),
|
||||
z.object({ action: z.literal("delete"), id: positiveId }),
|
||||
]),
|
||||
}),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const COMMUNITY_COMMANDS = createCommunityCommands(
|
||||
peopleMutationService,
|
||||
);
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
import {
|
||||
createPeopleMutationService,
|
||||
type PeopleMutationAdapter,
|
||||
} from "../services/mutations";
|
||||
import {
|
||||
createUserCommands,
|
||||
USER_COMMAND_IDS,
|
||||
USER_COMMANDS,
|
||||
} from "./user-commands";
|
||||
|
||||
const expectedUserCommandIds = [
|
||||
"people.user.update",
|
||||
"people.user.ban",
|
||||
"people.user.unban",
|
||||
"people.user.alert",
|
||||
"people.user.disconnect",
|
||||
"people.user.mute",
|
||||
"people.user.unmute",
|
||||
"people.user.reset-password",
|
||||
"people.user.send-currency",
|
||||
"people.user.trade-lock",
|
||||
"people.users.bulk-ban",
|
||||
"people.users.bulk-unban",
|
||||
"people.users.bulk-currency",
|
||||
"people.users.bulk-badge",
|
||||
] as const;
|
||||
|
||||
const commands = USER_COMMANDS as unknown as readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("People user commands", () => {
|
||||
it("declares the exact deterministic user command list", () => {
|
||||
expect(USER_COMMAND_IDS).toEqual(expectedUserCommandIds);
|
||||
expect(commands.map((command) => command.id)).toEqual(
|
||||
expectedUserCommandIds,
|
||||
);
|
||||
expect(commands.every((command) => command.owner === "people")).toBe(true);
|
||||
expect(commands.every((command) => command.risk === "sensitive")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the exact legacy ACL for every user operation", () => {
|
||||
const capabilities = Object.fromEntries(
|
||||
commands.map((command) => [command.id, command.capability.slugs]),
|
||||
);
|
||||
expect(capabilities).toEqual({
|
||||
"people.user.update": [PERMS.USERS_EDIT],
|
||||
"people.user.ban": [PERMS.USERS_BAN],
|
||||
"people.user.unban": [PERMS.USERS_BAN],
|
||||
"people.user.alert": [PERMS.USERS_EDIT],
|
||||
"people.user.disconnect": [PERMS.USERS_EDIT],
|
||||
"people.user.mute": [PERMS.USERS_EDIT],
|
||||
"people.user.unmute": [PERMS.USERS_EDIT],
|
||||
"people.user.reset-password": [PERMS.USERS_RESET_PASSWORD],
|
||||
"people.user.send-currency": [PERMS.USERS_EDIT],
|
||||
"people.user.trade-lock": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-ban": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-unban": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-currency": [PERMS.USERS_EDIT],
|
||||
"people.users.bulk-badge": [PERMS.USERS_EDIT],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires reasons for sanctions, security changes, currency, and bulk mutations", () => {
|
||||
const withoutReason = commands
|
||||
.filter((command) => !command.requiresReason)
|
||||
.map((command) => command.id);
|
||||
expect(withoutReason).toEqual(["people.user.update", "people.user.alert"]);
|
||||
|
||||
for (const command of commands.filter((entry) => entry.requiresReason)) {
|
||||
expect(
|
||||
confirmHousekeepingCommand(command, " ", "people-reason"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
fieldErrors: { reason: ["errors.validation.required"] },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["people.user.update", { userId: 7, fields: {} }],
|
||||
["people.user.alert", { userId: 7, message: " " }],
|
||||
["people.user.mute", { userId: 7, duration: 87_601 }],
|
||||
["people.user.send-currency", { userId: 7, amount: 1_000_001 }],
|
||||
["people.users.bulk-ban", { userIds: [], reason: "reason", duration: 0 }],
|
||||
[
|
||||
"people.users.bulk-badge",
|
||||
{
|
||||
userIds: Array.from({ length: 101 }, (_, index) => index + 1),
|
||||
badgeCode: "ADM",
|
||||
},
|
||||
],
|
||||
] as const)(
|
||||
"rejects an invalid bounded payload for %s",
|
||||
(commandId, input) => {
|
||||
const command = commands.find((entry) => entry.id === commandId);
|
||||
if (!command) throw new Error(`command missing: ${commandId}`);
|
||||
expect(command.input.safeParse(input).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("delegates parsed input and correlation to the redirect-free service", async () => {
|
||||
const execute = vi.fn(async (context, operation, input) => ({
|
||||
ok: true as const,
|
||||
data: { before: null, after: { operation, input } },
|
||||
correlationId: context.correlationId,
|
||||
}));
|
||||
const created = createUserCommands({
|
||||
execute,
|
||||
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
||||
const command = created.find(
|
||||
(entry) => entry.id === "people.user.send-currency",
|
||||
);
|
||||
if (!command) throw new Error("command missing");
|
||||
const input = command.input.parse({ userId: 7, amount: 25 });
|
||||
const result = await command.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "people-command",
|
||||
ipAddress: "198.51.100.8",
|
||||
},
|
||||
input,
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ correlationId: "people-command" }),
|
||||
"user.send-currency",
|
||||
{ userId: 7, amount: 25 },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: null, after: { operation: "user.send-currency" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("People mutation service boundary", () => {
|
||||
it("fails closed before the adapter when the exact capability is absent", async () => {
|
||||
const execute = vi.fn(async () => ({ before: null, after: null }));
|
||||
const service = createPeopleMutationService({ execute });
|
||||
const result = await service.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "denied-people",
|
||||
},
|
||||
"user.ban",
|
||||
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
correlationId: "denied-people",
|
||||
});
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns adapter before and after snapshots and sanitizes failures", async () => {
|
||||
const snapshotAdapter: PeopleMutationAdapter = {
|
||||
execute: async () => ({
|
||||
before: { rank: 2 },
|
||||
after: { rank: 3 },
|
||||
}),
|
||||
};
|
||||
const success = await createPeopleMutationService(snapshotAdapter).execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "snapshot-people",
|
||||
},
|
||||
"user.update",
|
||||
{ userId: 7, fields: { rank: 3 } },
|
||||
);
|
||||
expect(success).toMatchObject({
|
||||
ok: true,
|
||||
data: { before: { rank: 2 }, after: { rank: 3 } },
|
||||
});
|
||||
|
||||
const failureAdapter: PeopleMutationAdapter = {
|
||||
execute: async () => {
|
||||
throw new Error("database password=secret");
|
||||
},
|
||||
};
|
||||
const failure = await createPeopleMutationService(failureAdapter).execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "failed-people",
|
||||
},
|
||||
"user.update",
|
||||
{ userId: 7, fields: { motto: "Ready" } },
|
||||
);
|
||||
expect(failure).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(failure)).not.toContain("password=secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type PeopleMutationOperation,
|
||||
type PeopleMutationService,
|
||||
peopleMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
export const USER_COMMAND_IDS = [
|
||||
"people.user.update",
|
||||
"people.user.ban",
|
||||
"people.user.unban",
|
||||
"people.user.alert",
|
||||
"people.user.disconnect",
|
||||
"people.user.mute",
|
||||
"people.user.unmute",
|
||||
"people.user.reset-password",
|
||||
"people.user.send-currency",
|
||||
"people.user.trade-lock",
|
||||
"people.users.bulk-ban",
|
||||
"people.users.bulk-unban",
|
||||
"people.users.bulk-currency",
|
||||
"people.users.bulk-badge",
|
||||
] as const;
|
||||
|
||||
export type UserCommandId = (typeof USER_COMMAND_IDS)[number];
|
||||
|
||||
interface UserCommandOptions<I> {
|
||||
readonly id: UserCommandId;
|
||||
readonly operation: PeopleMutationOperation;
|
||||
readonly capability: string;
|
||||
readonly input: z.ZodType<I>;
|
||||
readonly requiresReason: boolean;
|
||||
readonly attempts?: number;
|
||||
}
|
||||
|
||||
function userCommand<I>(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
options: UserCommandOptions<I>,
|
||||
): HousekeepingCommand<I, unknown> {
|
||||
return {
|
||||
id: options.id,
|
||||
owner: "people",
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(options.capability),
|
||||
input: options.input,
|
||||
requiresReason: options.requiresReason,
|
||||
rateLimit: { attempts: options.attempts ?? 10, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
capability: context.capability,
|
||||
correlationId: context.correlationId,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const positiveId = z.number().int().positive();
|
||||
const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||
const userIds = z.array(positiveId).min(1).max(100);
|
||||
const optionalUpdateFields = {
|
||||
username: z.string().min(3).max(20).optional(),
|
||||
mail: z.email().optional(),
|
||||
rank: z.number().int().min(1).max(7).optional(),
|
||||
motto: z.string().max(127).optional(),
|
||||
credits: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
pixels: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
diamonds: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
duckets: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
};
|
||||
const updateFields = z.union([
|
||||
z.object({ ...optionalUpdateFields, username: z.string().min(3).max(20) }),
|
||||
z.object({ ...optionalUpdateFields, mail: z.email() }),
|
||||
z.object({ ...optionalUpdateFields, rank: z.number().int().min(1).max(7) }),
|
||||
z.object({ ...optionalUpdateFields, motto: z.string().max(127) }),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
credits: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
pixels: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
diamonds: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
z.object({
|
||||
...optionalUpdateFields,
|
||||
duckets: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
]);
|
||||
|
||||
export function createUserCommands(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
) {
|
||||
return [
|
||||
userCommand(service, {
|
||||
id: "people.user.update",
|
||||
operation: "user.update",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId, fields: updateFields }),
|
||||
requiresReason: false,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.ban",
|
||||
operation: "user.ban",
|
||||
capability: PERMS.USERS_BAN,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
reason: requiredText(500),
|
||||
duration: z.number().int().min(0).max(87_600),
|
||||
type: z.enum(["account", "ip", "machine"]),
|
||||
ip: z.string().max(255).optional(),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.unban",
|
||||
operation: "user.unban",
|
||||
capability: PERMS.USERS_BAN,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.alert",
|
||||
operation: "user.alert",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId, message: requiredText(500) }),
|
||||
requiresReason: false,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.disconnect",
|
||||
operation: "user.disconnect",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.mute",
|
||||
operation: "user.mute",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
duration: z.number().int().min(0).max(87_600),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.unmute",
|
||||
operation: "user.unmute",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.reset-password",
|
||||
operation: "user.reset-password",
|
||||
capability: PERMS.USERS_RESET_PASSWORD,
|
||||
input: z.object({ userId: positiveId }),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.send-currency",
|
||||
operation: "user.send-currency",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
amount: z.number().int().min(1).max(1_000_000),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.user.trade-lock",
|
||||
operation: "user.trade-lock",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
untilUnix: z.number().int().min(0).max(2_147_483_647),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 5,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-ban",
|
||||
operation: "users.bulk-ban",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userIds,
|
||||
reason: requiredText(500),
|
||||
duration: z.number().int().min(0).max(315_360_000),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-unban",
|
||||
operation: "users.bulk-unban",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userIds }),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-currency",
|
||||
operation: "users.bulk-currency",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({
|
||||
userIds,
|
||||
amount: z.number().int().min(1).max(1_000_000),
|
||||
type: z.enum(["credits", "pixels", "points"]),
|
||||
}),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
userCommand(service, {
|
||||
id: "people.users.bulk-badge",
|
||||
operation: "users.bulk-badge",
|
||||
capability: PERMS.USERS_EDIT,
|
||||
input: z.object({ userIds, badgeCode: requiredText(20) }),
|
||||
requiresReason: true,
|
||||
attempts: 3,
|
||||
}),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const USER_COMMANDS = createUserCommands(peopleMutationService);
|
||||
@@ -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: [],
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import {
|
||||
type PeopleCommunityQueryData,
|
||||
peopleCommunityQuery,
|
||||
} from "../queries/community";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
|
||||
interface PeopleCommunityPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleCommunityQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
function empty(data: PeopleCommunityQueryData) {
|
||||
return data.kind !== "guild" && data.page.items.length === 0;
|
||||
}
|
||||
|
||||
export function PeopleCommunityPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleCommunityPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Community"
|
||||
description="Review online users and guild ownership."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={empty}
|
||||
>
|
||||
{(data) => {
|
||||
if (data.kind === "online")
|
||||
return (
|
||||
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
{data.page.items.map((user) => (
|
||||
<li key={user.id}>
|
||||
<a href={user.href}>{user.username}</a> - {user.motto}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
if (data.kind === "guilds")
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((guild) => (
|
||||
<li
|
||||
key={guild.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<a href={guild.href}>{guild.name}</a>
|
||||
<p>{guild.memberCount} members</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2>{data.guild.name}</h2>
|
||||
<p>{data.guild.description}</p>
|
||||
<ul>
|
||||
{data.guild.members.map((member) => (
|
||||
<li key={member.id}>
|
||||
<a href={member.href}>{member.username}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.guild.disband">
|
||||
Disband guild
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleCommunityPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId;
|
||||
const result =
|
||||
routeId === "people.community.guild-detail"
|
||||
? (() => {
|
||||
const id = Number(input.match.params.id);
|
||||
return Number.isSafeInteger(id) && id > 0
|
||||
? peopleCommunityQuery.run(input.context, { routeId, id })
|
||||
: Promise.resolve(
|
||||
fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
createCorrelationId(),
|
||||
),
|
||||
);
|
||||
})()
|
||||
: routeId === "people.community.online" ||
|
||||
routeId === "people.community.guilds"
|
||||
? peopleCommunityQuery.run(input.context, { routeId, list: {} })
|
||||
: Promise.resolve(
|
||||
fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
createCorrelationId(),
|
||||
),
|
||||
);
|
||||
return <PeopleCommunityPage context={input.context} result={await result} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
|
||||
import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state";
|
||||
|
||||
interface PeoplePageFrameProps<T> {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly result?: HousekeepingResult<T>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
readonly isEmpty: (data: T) => boolean;
|
||||
readonly children: (data: T) => ReactNode;
|
||||
}
|
||||
|
||||
function state(
|
||||
kind: "loading" | "empty" | "error",
|
||||
title: string,
|
||||
description: string,
|
||||
) {
|
||||
return (
|
||||
<div data-housekeeping-state={kind}>
|
||||
<HousekeepingPageState
|
||||
state={kind}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeoplePageFrame<T>({
|
||||
title,
|
||||
description,
|
||||
result,
|
||||
partialDependencies = [],
|
||||
isEmpty,
|
||||
children,
|
||||
}: PeoplePageFrameProps<T>) {
|
||||
let body: ReactNode;
|
||||
if (!result) {
|
||||
body = state("loading", `Loading ${title.toLowerCase()}`, description);
|
||||
} else if (!result.ok) {
|
||||
if (result.error.code === "FORBIDDEN") {
|
||||
body = (
|
||||
<section
|
||||
role="alert"
|
||||
data-housekeeping-state="forbidden"
|
||||
className="rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
Access denied
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Your account cannot use this People workflow.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
} else if (result.error.code === "NOT_FOUND") {
|
||||
body = state(
|
||||
"empty",
|
||||
"Record not found",
|
||||
"The requested record is unavailable.",
|
||||
);
|
||||
} else {
|
||||
body = state(
|
||||
"error",
|
||||
`${title} unavailable`,
|
||||
`Request ${result.correlationId} could not be completed.`,
|
||||
);
|
||||
}
|
||||
} else if (isEmpty(result.data)) {
|
||||
body = state(
|
||||
"empty",
|
||||
`No ${title.toLowerCase()}`,
|
||||
"No matching records were returned.",
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div
|
||||
data-housekeeping-state={
|
||||
partialDependencies.length > 0 ? "partial" : "ready"
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
{partialDependencies.length > 0 ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial data"
|
||||
title="Some People data is unavailable"
|
||||
description={`Unavailable: ${partialDependencies.join(", ")}`}
|
||||
/>
|
||||
) : null}
|
||||
{children(result.data)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HousekeepingPageShell title={title} description={description}>
|
||||
{body}
|
||||
</HousekeepingPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../../../manifests";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../../route-handlers";
|
||||
import { peopleManifest } from "../manifest";
|
||||
import type { PeopleCommunityQueryData } from "../queries/community";
|
||||
import type { PeopleStaffQueryData } from "../queries/staff";
|
||||
import type { PeopleUsersQueryData } from "../queries/users";
|
||||
import {
|
||||
PEOPLE_PRIMARY_ROUTE_HANDLERS,
|
||||
PEOPLE_PRIMARY_ROUTE_IDS,
|
||||
} from "../route-handlers";
|
||||
import { PeopleCommunityPage } from "./community";
|
||||
import { PeopleMultiAccountsPage } from "./multi-accounts";
|
||||
import { PeopleStaffPage } from "./staff";
|
||||
import { PeopleUserDetailPage } from "./user-detail";
|
||||
import { PeopleUserEditPage } from "./user-edit";
|
||||
import { PeopleUsersPage } from "./users";
|
||||
|
||||
const correlationId = "people-pages-test";
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const readContext = capabilityContext([PERMS.USERS_VIEW]);
|
||||
const modReadContext = capabilityContext([PERMS.MOD_USERS_VIEW]);
|
||||
|
||||
const user = {
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
rank: 2,
|
||||
online: true,
|
||||
mail: "[email protected]",
|
||||
ipCurrent: "198.51.100.7",
|
||||
bannedUntil: null,
|
||||
href: "/ase/people/users/7" as const,
|
||||
};
|
||||
|
||||
const detail = {
|
||||
...user,
|
||||
motto: "Ready",
|
||||
look: "hd-180-1",
|
||||
accountCreated: "2026-08-01T00:00:00.000Z",
|
||||
lastLogin: "2026-08-28T00:00:00.000Z",
|
||||
sanctions: [],
|
||||
watched: false,
|
||||
permission: { rankName: "Member", ranks: [{ id: 2, name: "Member" }] },
|
||||
};
|
||||
|
||||
type PageCase = {
|
||||
readonly name: string;
|
||||
readonly render: (
|
||||
result?: HousekeepingResult<unknown>,
|
||||
partialDependencies?: readonly string[],
|
||||
) => string;
|
||||
readonly empty: unknown;
|
||||
readonly ready: unknown;
|
||||
};
|
||||
|
||||
const cases: readonly PageCase[] = [
|
||||
{
|
||||
name: "users",
|
||||
render: (result, partialDependencies) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleUsersPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
kind: "users",
|
||||
page: { items: [], total: 0, pageSize: 20, offset: 0 },
|
||||
},
|
||||
ready: {
|
||||
kind: "users",
|
||||
page: { items: [user], total: 1, pageSize: 20, offset: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user-detail",
|
||||
render: (result, partialDependencies) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleUserDetailPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: { kind: "user", user: { ...detail, sanctions: [] } },
|
||||
ready: {
|
||||
kind: "user",
|
||||
user: {
|
||||
...detail,
|
||||
sanctions: [
|
||||
{
|
||||
id: 3,
|
||||
kind: "account",
|
||||
reason: "spam",
|
||||
createdAt: null,
|
||||
expiresAt: 0,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user-edit",
|
||||
render: (result, partialDependencies) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleUserEditPage
|
||||
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
|
||||
ready: { kind: "user", user: { ...detail, watched: true } },
|
||||
},
|
||||
{
|
||||
name: "multi-accounts",
|
||||
render: (result, partialDependencies) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleMultiAccountsPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleUsersQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
kind: "multi-accounts",
|
||||
page: { items: [], total: 0, pageSize: 20, offset: 0 },
|
||||
},
|
||||
ready: {
|
||||
kind: "multi-accounts",
|
||||
page: {
|
||||
items: [{ key: "198.51.100.7", accountCount: 2, accounts: [user] }],
|
||||
total: 1,
|
||||
pageSize: 20,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "community",
|
||||
render: (result, partialDependencies) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleCommunityPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleCommunityQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
kind: "guilds",
|
||||
page: { items: [], total: 0, pageSize: 20, offset: 0 },
|
||||
},
|
||||
ready: {
|
||||
kind: "guilds",
|
||||
page: {
|
||||
items: [
|
||||
{
|
||||
id: 9,
|
||||
name: "Builders",
|
||||
description: "Rooms",
|
||||
userId: 7,
|
||||
ownerUsername: "Alice",
|
||||
roomId: 4,
|
||||
memberCount: 2,
|
||||
createdAt: null,
|
||||
href: "/ase/people/community/guilds/9",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
pageSize: 20,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "staff",
|
||||
render: (result, partialDependencies) =>
|
||||
renderToStaticMarkup(
|
||||
<PeopleStaffPage
|
||||
context={readContext}
|
||||
result={result as HousekeepingResult<PeopleStaffQueryData>}
|
||||
partialDependencies={partialDependencies}
|
||||
/>,
|
||||
),
|
||||
empty: {
|
||||
kind: "applications",
|
||||
page: { items: [], total: 0, pageSize: 20, offset: 0 },
|
||||
},
|
||||
ready: {
|
||||
kind: "applications",
|
||||
page: {
|
||||
items: [
|
||||
{
|
||||
id: 5,
|
||||
userId: 7,
|
||||
username: "Alice",
|
||||
rankId: 4,
|
||||
content: "Experienced",
|
||||
createdAt: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
pageSize: 20,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(cases)("People $name page", ({ render, empty, ready }) => {
|
||||
it("renders loading, forbidden, dependency error, empty, partial, and ready states", () => {
|
||||
expect(render()).toContain('data-housekeeping-state="loading"');
|
||||
expect(
|
||||
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)),
|
||||
).toContain('data-housekeeping-state="forbidden"');
|
||||
expect(
|
||||
render(
|
||||
fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
correlationId,
|
||||
),
|
||||
),
|
||||
).toContain('data-housekeeping-state="error"');
|
||||
const emptyResult =
|
||||
typeof empty === "object" && empty !== null && "ok" in empty
|
||||
? (empty as HousekeepingResult<unknown>)
|
||||
: ok(empty, correlationId);
|
||||
expect(render(emptyResult)).toContain('data-housekeeping-state="empty"');
|
||||
expect(render(ok(ready, correlationId), ["secondary"])).toContain(
|
||||
'data-housekeeping-state="partial"',
|
||||
);
|
||||
expect(render(ok(ready, correlationId))).toContain(
|
||||
'data-housekeeping-state="ready"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("People primary route registration", () => {
|
||||
it("registers exactly the nine real primary routes and handlers", () => {
|
||||
const expected = [
|
||||
"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",
|
||||
];
|
||||
expect(PEOPLE_PRIMARY_ROUTE_IDS).toEqual(expected);
|
||||
expect(peopleManifest.routes.map((route) => route.id)).toEqual(expected);
|
||||
expect(
|
||||
PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||
).toEqual(expected);
|
||||
expect(
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||
).toEqual(expect.arrayContaining(expected));
|
||||
expect(
|
||||
HOUSEKEEPING_MANIFESTS.flatMap((manifest) => manifest.routes).map(
|
||||
(route) => route.id,
|
||||
),
|
||||
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
|
||||
});
|
||||
|
||||
it("uses canonical People links and exposes PII and actions only with exact capabilities", () => {
|
||||
const redacted = {
|
||||
...detail,
|
||||
mail: null,
|
||||
ipCurrent: null,
|
||||
};
|
||||
const modHtml = renderToStaticMarkup(
|
||||
<PeopleUserDetailPage
|
||||
context={modReadContext}
|
||||
result={ok({ kind: "user", user: redacted }, correlationId)}
|
||||
/>,
|
||||
);
|
||||
expect(modHtml).not.toContain("[email protected]");
|
||||
expect(modHtml).not.toContain("198.51.100.7");
|
||||
expect(modHtml).not.toContain("people.user.update");
|
||||
|
||||
const editorHtml = renderToStaticMarkup(
|
||||
<PeopleUserDetailPage
|
||||
context={capabilityContext([PERMS.USERS_VIEW, PERMS.USERS_EDIT])}
|
||||
result={ok({ kind: "user", user: detail }, correlationId)}
|
||||
/>,
|
||||
);
|
||||
expect(editorHtml).toContain('href="/ase/people/users/7/edit"');
|
||||
expect(editorHtml).toContain("people.user.update");
|
||||
expect(editorHtml).not.toContain("/admin");
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
|
||||
interface PeopleUserDetailPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleUserDetailPage({
|
||||
context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleUserDetailPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="User detail"
|
||||
description="Review account state, permissions, and sanctions."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={() => false}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "user" ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
{data.user.username}
|
||||
</h2>
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<a
|
||||
href={`${data.user.href}/edit`}
|
||||
data-housekeeping-command="people.user.update"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
|
||||
<dt>Rank</dt>
|
||||
<dd>{data.user.permission.rankName}</dd>
|
||||
<dt>Status</dt>
|
||||
<dd>{data.user.online ? "Online" : "Offline"}</dd>
|
||||
<dt>Motto</dt>
|
||||
<dd>{data.user.motto || "None"}</dd>
|
||||
{data.user.mail !== null ? (
|
||||
<>
|
||||
<dt>Email</dt>
|
||||
<dd>{data.user.mail}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{data.user.ipCurrent !== null ? (
|
||||
<>
|
||||
<dt>Current IP</dt>
|
||||
<dd>{data.user.ipCurrent}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<span data-housekeeping-command="people.user.ban">Ban</span>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_EDIT) ? (
|
||||
<span data-housekeeping-command="people.user.disconnect">
|
||||
Disconnect
|
||||
</span>
|
||||
) : null}
|
||||
{context.has(PERMS.USERS_RESET_PASSWORD) ? (
|
||||
<span data-housekeeping-command="people.user.reset-password">
|
||||
Reset password
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
Sanctions
|
||||
</h2>
|
||||
{data.user.sanctions.length === 0 ? (
|
||||
<p
|
||||
data-housekeeping-state="empty"
|
||||
className="mt-3 text-sm text-[var(--admin-text-muted)]"
|
||||
>
|
||||
No sanctions recorded.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2 text-sm">
|
||||
{data.user.sanctions.map((sanction) => (
|
||||
<li key={sanction.id}>
|
||||
{sanction.kind}: {sanction.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleUserDetailPage(input: HousekeepingPageInput) {
|
||||
const id = Number(input.match.params.id);
|
||||
const result =
|
||||
Number.isSafeInteger(id) && id > 0
|
||||
? await peopleUsersQuery.run(input.context, {
|
||||
routeId: "people.users.detail",
|
||||
id,
|
||||
})
|
||||
: fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
createCorrelationId(),
|
||||
);
|
||||
return <PeopleUserDetailPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
|
||||
interface PeopleUserEditPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
|
||||
readonly partialDependencies?: readonly string[];
|
||||
}
|
||||
|
||||
export function PeopleUserEditPage({
|
||||
context: _context,
|
||||
result,
|
||||
partialDependencies,
|
||||
}: PeopleUserEditPageProps) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Edit user"
|
||||
description="Update bounded account fields through an audited command."
|
||||
result={result}
|
||||
partialDependencies={partialDependencies}
|
||||
isEmpty={() => false}
|
||||
>
|
||||
{(data) =>
|
||||
data.kind === "user" ? (
|
||||
<form
|
||||
data-housekeeping-command="people.user.update"
|
||||
className="space-y-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<input type="hidden" name="userId" value={data.user.id} />
|
||||
<label className="block text-sm">
|
||||
Username
|
||||
<input
|
||||
name="username"
|
||||
defaultValue={data.user.username}
|
||||
maxLength={20}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
{data.user.mail !== null ? (
|
||||
<label className="block text-sm">
|
||||
Email
|
||||
<input
|
||||
name="mail"
|
||||
type="email"
|
||||
defaultValue={data.user.mail}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
Motto
|
||||
<input
|
||||
name="motto"
|
||||
defaultValue={data.user.motto}
|
||||
maxLength={127}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Rank
|
||||
<select
|
||||
name="rank"
|
||||
defaultValue={data.user.rank}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
{data.user.permission.ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save user</button>
|
||||
</form>
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleUserEditPage(input: HousekeepingPageInput) {
|
||||
const id = Number(input.match.params.id);
|
||||
const result =
|
||||
Number.isSafeInteger(id) && id > 0
|
||||
? await peopleUsersQuery.run(input.context, {
|
||||
routeId: "people.users.edit",
|
||||
id,
|
||||
})
|
||||
: fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
createCorrelationId(),
|
||||
);
|
||||
return <PeopleUserEditPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const { audit, alertUser, selectLimit } = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
alertUser: vi.fn(),
|
||||
selectLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...actual.db,
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ limit: selectLimit })),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||
logAudit: audit,
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
|
||||
rcon: { alertUser },
|
||||
}));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const target = {
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
mail: "[email protected]",
|
||||
rank: 2,
|
||||
motto: "Ready",
|
||||
credits: 100,
|
||||
pixels: 50,
|
||||
online: "1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectLimit.mockResolvedValue([target]);
|
||||
alertUser.mockResolvedValue(true);
|
||||
audit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("People production mutation boundary", () => {
|
||||
it("rechecks authorization before any production adapter work", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{ capability: capabilityContext([]), correlationId: "production-denied" },
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(selectLimit).not.toHaveBeenCalled();
|
||||
expect(alertUser).not.toHaveBeenCalled();
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits sanitized before and after evidence for a successful mutation", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "production-alert",
|
||||
},
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
before: { id: 7, username: "Alice", online: true },
|
||||
after: { id: 7, alertDelivered: true },
|
||||
},
|
||||
});
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "people.user.alert",
|
||||
before: expect.objectContaining({ id: 7 }),
|
||||
after: expect.objectContaining({ alertDelivered: true }),
|
||||
correlationId: "production-alert",
|
||||
outcome: "success",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when required audit evidence cannot be persisted", async () => {
|
||||
audit.mockRejectedValue(new Error("audit database unavailable"));
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_EDIT]),
|
||||
correlationId: "production-audit-failure",
|
||||
},
|
||||
"user.alert",
|
||||
{ userId: 7, message: "Hello" },
|
||||
);
|
||||
expect(alertUser).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
correlationId: "production-audit-failure",
|
||||
});
|
||||
});
|
||||
});
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const { audit, disconnectUser, insertValues, selectLimit } = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
disconnectUser: vi.fn(),
|
||||
insertValues: vi.fn(),
|
||||
selectLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...actual.db,
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ limit: selectLimit })),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||
logAudit: audit,
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
|
||||
rcon: { disconnectUser },
|
||||
}));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { peopleMutationService } from "./mutations";
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 6 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectLimit.mockResolvedValue([
|
||||
{
|
||||
id: 7,
|
||||
username: "Alice",
|
||||
mail: "[email protected]",
|
||||
rank: 2,
|
||||
motto: "Ready",
|
||||
credits: 100,
|
||||
pixels: 50,
|
||||
online: "1",
|
||||
},
|
||||
]);
|
||||
insertValues.mockResolvedValue([{}]);
|
||||
disconnectUser.mockResolvedValue(true);
|
||||
audit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("People production reason audit", () => {
|
||||
it("persists the nonblank sanction reason with before and after evidence", async () => {
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
capability: capabilityContext([PERMS.USERS_BAN]),
|
||||
correlationId: "production-ban",
|
||||
},
|
||||
"user.ban",
|
||||
{
|
||||
userId: 7,
|
||||
reason: "Repeated harassment",
|
||||
duration: 24,
|
||||
type: "account",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "people.user.ban",
|
||||
before: expect.objectContaining({ id: 7, banned: false }),
|
||||
after: expect.objectContaining({
|
||||
id: 7,
|
||||
banned: true,
|
||||
reason: "Repeated harassment",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
|
||||
expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,995 @@
|
||||
import "server-only";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, inArray, max, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { invalidateLoginCache } from "@/lib/auth";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import {
|
||||
Ban,
|
||||
db,
|
||||
GuildForumViews,
|
||||
Guilds,
|
||||
GuildsForumsComments,
|
||||
GuildsForumsThreads,
|
||||
GuildsMembers,
|
||||
Items,
|
||||
Rooms,
|
||||
Sanctions,
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
WebsiteIpBlacklist,
|
||||
WebsiteIpWhitelist,
|
||||
WebsiteSetting,
|
||||
WebsiteStaffApplications,
|
||||
WebsiteTeams,
|
||||
WebsiteWordfilter,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { reloadWordFilter } from "@/lib/services/moderation";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
|
||||
export type PeopleMutationOperation =
|
||||
| "user.update"
|
||||
| "user.ban"
|
||||
| "user.unban"
|
||||
| "user.alert"
|
||||
| "user.disconnect"
|
||||
| "user.mute"
|
||||
| "user.unmute"
|
||||
| "user.reset-password"
|
||||
| "user.send-currency"
|
||||
| "user.trade-lock"
|
||||
| "users.bulk-ban"
|
||||
| "users.bulk-unban"
|
||||
| "users.bulk-currency"
|
||||
| "users.bulk-badge"
|
||||
| "guild.disband"
|
||||
| "application.decide"
|
||||
| "team.change"
|
||||
| "ip.action"
|
||||
| "vpn.configure"
|
||||
| "word-filter.update";
|
||||
|
||||
export interface PeopleMutationSnapshot {
|
||||
readonly before: Readonly<Record<string, unknown>> | null;
|
||||
readonly after: Readonly<Record<string, unknown>> | null;
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface PeopleMutationContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface PeopleMutationAdapter {
|
||||
execute(
|
||||
operation: PeopleMutationOperation,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot>;
|
||||
}
|
||||
|
||||
export interface PeopleMutationService {
|
||||
execute(
|
||||
context: PeopleMutationContext,
|
||||
operation: PeopleMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<HousekeepingResult<PeopleMutationSnapshot>>;
|
||||
}
|
||||
|
||||
class PeopleMutationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: HousekeepingErrorCode,
|
||||
readonly messageKey: string,
|
||||
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
) {
|
||||
super(messageKey);
|
||||
this.name = "PeopleMutationFailure";
|
||||
}
|
||||
}
|
||||
|
||||
function operationCapability(operation: PeopleMutationOperation) {
|
||||
if (operation === "user.ban" || operation === "user.unban") {
|
||||
return anyCapability(PERMS.USERS_BAN);
|
||||
}
|
||||
if (operation === "user.reset-password") {
|
||||
return anyCapability(PERMS.USERS_RESET_PASSWORD);
|
||||
}
|
||||
if (operation === "ip.action" || operation === "vpn.configure") {
|
||||
return anyCapability(PERMS.SETTINGS_EDIT);
|
||||
}
|
||||
if (operation === "word-filter.update") {
|
||||
return anyCapability(PERMS.WORDFILTER_EDIT);
|
||||
}
|
||||
return anyCapability(PERMS.USERS_EDIT);
|
||||
}
|
||||
|
||||
export function createPeopleMutationService(
|
||||
adapter: PeopleMutationAdapter,
|
||||
): PeopleMutationService {
|
||||
return {
|
||||
async execute(context, operation, input) {
|
||||
if (
|
||||
!satisfiesCapability(context.capability, operationCapability(operation))
|
||||
) {
|
||||
return fail(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
context.correlationId,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return ok(
|
||||
await adapter.execute(operation, input, context),
|
||||
context.correlationId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PeopleMutationFailure) {
|
||||
return fail(
|
||||
error.code,
|
||||
error.messageKey,
|
||||
context.correlationId,
|
||||
error.fieldErrors,
|
||||
);
|
||||
}
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
context.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyPeopleMutationContext(
|
||||
staff: {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly rank: number;
|
||||
},
|
||||
permission: string,
|
||||
correlationId: string,
|
||||
): PeopleMutationContext {
|
||||
return {
|
||||
correlationId,
|
||||
capability: {
|
||||
actor: { id: staff.id, username: staff.username, rank: staff.rank },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === permission,
|
||||
hasAny: (...slugs) => slugs.includes(permission),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === permission),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function record(input: unknown): Record<string, unknown> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizedText(value: unknown, max: number, required = true): string {
|
||||
const text = String(value ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
if (required && text.length === 0) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function userIds(value: unknown): number[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return [...new Set(value.map(positiveInteger))];
|
||||
}
|
||||
|
||||
async function requireRcon(result: boolean): Promise<void> {
|
||||
if (!result) {
|
||||
throw new PeopleMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type TargetUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
};
|
||||
|
||||
async function loadTarget(
|
||||
userId: number,
|
||||
context: PeopleMutationContext,
|
||||
guardHierarchy: boolean,
|
||||
): Promise<TargetUser> {
|
||||
const [target] = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
rank: User.rank,
|
||||
motto: User.motto,
|
||||
credits: User.credits,
|
||||
pixels: User.pixels,
|
||||
online: User.online,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
if (
|
||||
guardHierarchy &&
|
||||
target.rank >= context.capability.actor.rank &&
|
||||
context.capability.actor.rank < 7
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function targetSnapshot(target: TargetUser) {
|
||||
return {
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
rank: target.rank,
|
||||
motto: target.motto,
|
||||
credits: target.credits,
|
||||
pixels: target.pixels,
|
||||
online: target.online === "1",
|
||||
};
|
||||
}
|
||||
|
||||
async function auditMutation(
|
||||
context: PeopleMutationContext,
|
||||
operation: PeopleMutationOperation,
|
||||
target: string,
|
||||
targetId: number | undefined,
|
||||
snapshot: PeopleMutationSnapshot,
|
||||
): Promise<void> {
|
||||
await logAudit({
|
||||
userId: context.capability.actor.id,
|
||||
action: `people.${operation}`,
|
||||
target,
|
||||
targetId,
|
||||
before: snapshot.before ?? undefined,
|
||||
after: snapshot.after ?? undefined,
|
||||
correlationId: context.correlationId,
|
||||
outcome: "success",
|
||||
domain: "people",
|
||||
});
|
||||
}
|
||||
|
||||
async function executeUserMutation(
|
||||
operation: Extract<PeopleMutationOperation, `user.${string}`>,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const userId = positiveInteger(data.userId);
|
||||
const guardHierarchy = operation !== "user.alert";
|
||||
const target = await loadTarget(userId, context, guardHierarchy);
|
||||
const before = targetSnapshot(target);
|
||||
let snapshot: PeopleMutationSnapshot;
|
||||
|
||||
if (operation === "user.update") {
|
||||
const fields = record(data.fields);
|
||||
const nextRank = fields.rank;
|
||||
if (
|
||||
nextRank !== undefined &&
|
||||
positiveInteger(nextRank) >= context.capability.actor.rank &&
|
||||
context.capability.actor.rank < 7
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
);
|
||||
}
|
||||
const userPatch = Object.fromEntries(
|
||||
["username", "mail", "rank", "motto", "credits", "pixels"].flatMap(
|
||||
(key) => (fields[key] === undefined ? [] : [[key, fields[key]]]),
|
||||
),
|
||||
);
|
||||
if (Object.keys(userPatch).length > 0) {
|
||||
await db.update(User).set(userPatch).where(eq(User.id, userId));
|
||||
}
|
||||
for (const [key, type] of [
|
||||
["duckets", 0],
|
||||
["diamonds", 5],
|
||||
] as const) {
|
||||
if (fields[key] === undefined) continue;
|
||||
const amount = nonNegativeInteger(fields[key]);
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type, amount })
|
||||
.onDuplicateKeyUpdate({ set: { amount } });
|
||||
}
|
||||
invalidateLoginCache(target.username);
|
||||
snapshot = { before, after: { ...before, ...fields } };
|
||||
await notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
targetId: userId,
|
||||
});
|
||||
} else if (operation === "user.ban") {
|
||||
const reason = normalizedText(data.reason, 500);
|
||||
const duration = nonNegativeInteger(data.duration);
|
||||
const typeValue = normalizedText(data.type || "account", 32);
|
||||
if (
|
||||
typeValue !== "account" &&
|
||||
typeValue !== "ip" &&
|
||||
typeValue !== "machine"
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
const type: "account" | "ip" | "machine" = typeValue;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire = duration > 0 ? now + duration * 3600 : 0;
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
userStaffId: context.capability.actor.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type,
|
||||
ip: normalizedText(data.ip, 255, false),
|
||||
machineId: "",
|
||||
});
|
||||
await rcon.disconnectUser(userId);
|
||||
snapshot = {
|
||||
before: { ...before, banned: false },
|
||||
after: { ...before, banned: true, banExpire, type, reason },
|
||||
};
|
||||
await notify({
|
||||
action: "ban",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
details: reason,
|
||||
});
|
||||
} else if (operation === "user.unban") {
|
||||
await db.delete(Ban).where(eq(Ban.userId, userId));
|
||||
snapshot = {
|
||||
before: { ...before, banned: true },
|
||||
after: { ...before, banned: false },
|
||||
};
|
||||
await notify({
|
||||
action: "unban",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
});
|
||||
} else if (operation === "user.alert") {
|
||||
const message = normalizedText(data.message, 500);
|
||||
await requireRcon(await rcon.alertUser(userId, message));
|
||||
snapshot = { before, after: { ...before, alertDelivered: true } };
|
||||
} else if (operation === "user.disconnect") {
|
||||
await requireRcon(await rcon.disconnectUser(userId));
|
||||
snapshot = { before, after: { ...before, online: false } };
|
||||
await notify({
|
||||
action: "disconnect",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
});
|
||||
} else if (operation === "user.mute") {
|
||||
const duration = nonNegativeInteger(data.duration);
|
||||
await requireRcon(await rcon.muteUser(userId, duration));
|
||||
snapshot = { before, after: { ...before, muted: true, duration } };
|
||||
} else if (operation === "user.unmute") {
|
||||
await requireRcon(await rcon.unmuteUser(userId));
|
||||
snapshot = { before, after: { ...before, muted: false } };
|
||||
} else if (operation === "user.reset-password") {
|
||||
const newPassword = crypto
|
||||
.randomBytes(12)
|
||||
.toString("base64url")
|
||||
.slice(0, 16);
|
||||
await db
|
||||
.update(User)
|
||||
.set({ password: await hashPassword(newPassword) })
|
||||
.where(eq(User.id, userId));
|
||||
invalidateLoginCache(target.username);
|
||||
snapshot = {
|
||||
before: { ...before, passwordReset: false },
|
||||
after: { ...before, passwordReset: true },
|
||||
output: { newPassword },
|
||||
};
|
||||
await notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
} else if (operation === "user.send-currency") {
|
||||
const amount = positiveInteger(data.amount);
|
||||
await requireRcon(await rcon.giveCredits(userId, amount));
|
||||
snapshot = { before, after: { ...before, creditsSent: amount } };
|
||||
} else {
|
||||
const untilUnix = nonNegativeInteger(data.untilUnix);
|
||||
const locked = untilUnix > 0;
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: Sanctions.id })
|
||||
.from(Sanctions)
|
||||
.where(eq(Sanctions.habboId, userId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(Sanctions)
|
||||
.set({
|
||||
tradeLockedUntil: untilUnix,
|
||||
...(locked ? { reason: "Trade lock (CMS)" } : {}),
|
||||
})
|
||||
.where(eq(Sanctions.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(Sanctions).values({
|
||||
habboId: userId,
|
||||
tradeLockedUntil: untilUnix,
|
||||
reason: locked ? "Trade lock (CMS)" : "",
|
||||
});
|
||||
}
|
||||
await tx
|
||||
.update(UsersSettings)
|
||||
.set({
|
||||
canTrade: locked ? "0" : "1",
|
||||
...(locked
|
||||
? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(UsersSettings.userId, userId));
|
||||
});
|
||||
await rcon.setTradeLock(userId, locked);
|
||||
await rcon.alertUser(
|
||||
userId,
|
||||
locked
|
||||
? "Trading has been disabled by staff."
|
||||
: "Trading has been re-enabled by staff.",
|
||||
);
|
||||
if (target.online === "1") {
|
||||
await rcon.disconnectUser(userId, target.username);
|
||||
}
|
||||
snapshot = {
|
||||
before: { ...before, tradeLocked: !locked },
|
||||
after: { ...before, tradeLocked: locked, untilUnix },
|
||||
output: { userId, untilUnix },
|
||||
};
|
||||
}
|
||||
|
||||
await auditMutation(context, operation, "User", userId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeBulkMutation(
|
||||
operation: Extract<PeopleMutationOperation, `users.${string}`>,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const ids = userIds(data.userIds);
|
||||
const failures: Array<{ userId: number; reason: string }> = [];
|
||||
let completed = 0;
|
||||
let mutationReason: string | undefined;
|
||||
|
||||
if (operation === "users.bulk-unban") {
|
||||
const result = await db.delete(Ban).where(inArray(Ban.userId, ids));
|
||||
completed = Number(
|
||||
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||
);
|
||||
} else if (operation === "users.bulk-ban") {
|
||||
const reason = normalizedText(data.reason, 500);
|
||||
mutationReason = reason;
|
||||
const durationSeconds = nonNegativeInteger(data.duration);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: context.capability.actor.id,
|
||||
timestamp: now,
|
||||
banExpire: durationSeconds > 0 ? now + durationSeconds : 0,
|
||||
banReason: reason,
|
||||
type: "account",
|
||||
});
|
||||
completed += 1;
|
||||
} catch {
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
} else if (operation === "users.bulk-currency") {
|
||||
const amount = positiveInteger(data.amount);
|
||||
const type = normalizedText(data.type, 16) as
|
||||
| "credits"
|
||||
| "pixels"
|
||||
| "points";
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||
.where(eq(User.id, userId));
|
||||
await rcon.giveCredits(userId, amount);
|
||||
} else {
|
||||
const currencyType = type === "pixels" ? 0 : 101;
|
||||
await db
|
||||
.insert(UsersCurrency)
|
||||
.values({ userId, type: currencyType, amount })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
if (type === "pixels") await rcon.giveDuckets(userId, amount);
|
||||
else await rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const badgeCode = normalizedText(data.badgeCode, 20);
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(
|
||||
eq(UsersBadges.userId, userId),
|
||||
eq(UsersBadges.badgeCode, badgeCode),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
const [aggregate] = await db
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
await db.insert(UsersBadges).values({
|
||||
userId,
|
||||
slotId: (aggregate?.maxSlot ?? 0) + 1,
|
||||
badgeCode,
|
||||
});
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
before: { userIds: ids },
|
||||
after: {
|
||||
completed,
|
||||
total: ids.length,
|
||||
failedIds: failures,
|
||||
...(mutationReason ? { reason: mutationReason } : {}),
|
||||
},
|
||||
};
|
||||
await auditMutation(context, operation, "User", undefined, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeGuildDisband(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const guildId = positiveInteger(record(input).guildId);
|
||||
const [guild] = await db
|
||||
.select({ id: Guilds.id, name: Guilds.name, userId: Guilds.userId })
|
||||
.from(Guilds)
|
||||
.where(eq(Guilds.id, guildId))
|
||||
.limit(1);
|
||||
if (!guild) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const threads = await tx
|
||||
.select({ id: GuildsForumsThreads.id })
|
||||
.from(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, guildId));
|
||||
const threadIds = threads.map((thread) => thread.id);
|
||||
if (threadIds.length > 0) {
|
||||
await tx
|
||||
.delete(GuildsForumsComments)
|
||||
.where(inArray(GuildsForumsComments.threadId, threadIds));
|
||||
await tx
|
||||
.delete(GuildsForumsThreads)
|
||||
.where(eq(GuildsForumsThreads.guildId, guildId));
|
||||
}
|
||||
await tx
|
||||
.delete(GuildForumViews)
|
||||
.where(eq(GuildForumViews.guildId, guildId));
|
||||
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, guildId));
|
||||
await tx
|
||||
.update(Rooms)
|
||||
.set({ guildId: 0 })
|
||||
.where(eq(Rooms.guildId, guildId));
|
||||
await tx
|
||||
.update(Items)
|
||||
.set({ guildId: 0 })
|
||||
.where(eq(Items.guildId, guildId));
|
||||
await tx.delete(Guilds).where(eq(Guilds.id, guildId));
|
||||
});
|
||||
const snapshot = {
|
||||
before: { id: guild.id, name: guild.name, userId: guild.userId },
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(context, "guild.disband", "Guild", guildId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeApplicationDecision(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const applicationId = positiveInteger(data.applicationId);
|
||||
if (data.decision !== "dismiss") {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
const [application] = await db
|
||||
.select({
|
||||
id: WebsiteStaffApplications.id,
|
||||
userId: WebsiteStaffApplications.userId,
|
||||
rankId: WebsiteStaffApplications.rankId,
|
||||
content: WebsiteStaffApplications.content,
|
||||
})
|
||||
.from(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, BigInt(applicationId)))
|
||||
.limit(1);
|
||||
if (!application) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, BigInt(applicationId)));
|
||||
const snapshot = {
|
||||
before: {
|
||||
id: Number(application.id),
|
||||
userId: application.userId,
|
||||
rankId: application.rankId,
|
||||
content: application.content,
|
||||
},
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(
|
||||
context,
|
||||
"application.decide",
|
||||
"StaffApplication",
|
||||
applicationId,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeTeamChange(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
if (data.action === "delete") {
|
||||
const teamId = positiveInteger(data.teamId);
|
||||
const [team] = await db
|
||||
.select({
|
||||
id: WebsiteTeams.id,
|
||||
rankName: WebsiteTeams.rankName,
|
||||
badge: WebsiteTeams.badge,
|
||||
jobDescription: WebsiteTeams.jobDescription,
|
||||
hiddenRank: WebsiteTeams.hiddenRank,
|
||||
})
|
||||
.from(WebsiteTeams)
|
||||
.where(eq(WebsiteTeams.id, BigInt(teamId)))
|
||||
.limit(1);
|
||||
if (!team) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, BigInt(teamId)));
|
||||
const snapshot = {
|
||||
before: { ...team, id: Number(team.id) },
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(context, "team.change", "Team", teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const rankName = normalizedText(data.rankName, 255);
|
||||
const badge = normalizedText(data.badge, 255, false);
|
||||
const jobDescription = normalizedText(data.jobDescription, 255, false);
|
||||
const staffColor = normalizedText(data.staffColor || "#327fa8", 255);
|
||||
const hiddenRank = Boolean(data.hiddenRank);
|
||||
const now = new Date();
|
||||
const [result] = await db.insert(WebsiteTeams).values({
|
||||
rankName,
|
||||
badge: badge || null,
|
||||
jobDescription: jobDescription || null,
|
||||
staffColor,
|
||||
hiddenRank,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const teamId = Number(result.insertId);
|
||||
const snapshot = {
|
||||
before: null,
|
||||
after: {
|
||||
teamId,
|
||||
rankName,
|
||||
badge: badge || null,
|
||||
jobDescription: jobDescription || null,
|
||||
staffColor,
|
||||
hiddenRank,
|
||||
},
|
||||
};
|
||||
await auditMutation(context, "team.change", "Team", teamId, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeIpAction(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const action = normalizedText(data.action, 32);
|
||||
const blacklist = action.endsWith("blacklist");
|
||||
const adding = action.startsWith("add-");
|
||||
const table = blacklist ? WebsiteIpBlacklist : WebsiteIpWhitelist;
|
||||
if (adding) {
|
||||
const ipAddress = normalizedText(data.ipAddress, 255);
|
||||
const asn = normalizedText(data.asn, 255, false) || null;
|
||||
const [result] = blacklist
|
||||
? await db
|
||||
.insert(WebsiteIpBlacklist)
|
||||
.values({ ipAddress, asn, blacklistAsn: asn !== null })
|
||||
: await db
|
||||
.insert(WebsiteIpWhitelist)
|
||||
.values({ ipAddress, asn, whitelistAsn: asn !== null });
|
||||
const id = Number(result.insertId);
|
||||
const snapshot = { before: null, after: { id, action, ipAddress, asn } };
|
||||
await auditMutation(context, "ip.action", "IpRule", id, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
await db.delete(table).where(eq(table.id, BigInt(id)));
|
||||
const snapshot = { before: { id, action }, after: null };
|
||||
await auditMutation(context, "ip.action", "IpRule", id, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function writeVpnSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
comment: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
async function executeVpnConfiguration(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const enabled = Boolean(data.enabled);
|
||||
const provider = normalizedText(data.provider, 32);
|
||||
const apiKey = normalizedText(data.apiKey, 255, false);
|
||||
const blockMessage = normalizedText(data.blockMessage, 255, false);
|
||||
const [storedEnabled, storedProvider, storedApiKey, storedBlockMessage] =
|
||||
await Promise.all([
|
||||
siteSettings.get("vpn_block_enabled"),
|
||||
siteSettings.get("vpn_provider"),
|
||||
siteSettings.get("vpn_api_key"),
|
||||
siteSettings.get("vpn_block_message"),
|
||||
]);
|
||||
const before = {
|
||||
enabled: storedEnabled === "1",
|
||||
provider: storedProvider ?? "none",
|
||||
apiKeyConfigured: Boolean(storedApiKey),
|
||||
blockMessage: storedBlockMessage ?? "",
|
||||
};
|
||||
await writeVpnSetting(
|
||||
"vpn_block_enabled",
|
||||
enabled ? "1" : "0",
|
||||
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||
);
|
||||
await writeVpnSetting(
|
||||
"vpn_provider",
|
||||
provider,
|
||||
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||
);
|
||||
await writeVpnSetting(
|
||||
"vpn_api_key",
|
||||
apiKey,
|
||||
"API key for the VPN/proxy detection provider",
|
||||
);
|
||||
await writeVpnSetting(
|
||||
"vpn_block_message",
|
||||
blockMessage,
|
||||
"Message shown to users blocked for using a VPN/proxy",
|
||||
);
|
||||
await siteSettings.reload();
|
||||
const snapshot = {
|
||||
before,
|
||||
after: {
|
||||
enabled,
|
||||
provider,
|
||||
apiKeyConfigured: apiKey.length > 0,
|
||||
blockMessage,
|
||||
},
|
||||
};
|
||||
await auditMutation(
|
||||
context,
|
||||
"vpn.configure",
|
||||
"VpnConfiguration",
|
||||
undefined,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeWordFilterUpdate(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
if (data.action === "add") {
|
||||
const word = normalizedText(data.word, 255);
|
||||
const [result] = (await db
|
||||
.insert(WebsiteWordfilter)
|
||||
.values({ word })) as unknown as [ResultSetHeader];
|
||||
const id = Number(result.insertId);
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
const snapshot = { before: null, after: { id, word } };
|
||||
await auditMutation(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteWordfilter.id, word: WebsiteWordfilter.word })
|
||||
.from(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(id)))
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
await db
|
||||
.delete(WebsiteWordfilter)
|
||||
.where(eq(WebsiteWordfilter.id, BigInt(id)));
|
||||
reloadWordFilter();
|
||||
await rcon.updateWordFilter();
|
||||
const snapshot = {
|
||||
before: { id: Number(existing.id), word: existing.word },
|
||||
after: null,
|
||||
};
|
||||
await auditMutation(
|
||||
context,
|
||||
"word-filter.update",
|
||||
"WordFilter",
|
||||
id,
|
||||
snapshot,
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||
async execute(operation, input, context) {
|
||||
if (operation.startsWith("user.")) {
|
||||
return executeUserMutation(
|
||||
operation as Extract<PeopleMutationOperation, `user.${string}`>,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (operation.startsWith("users.")) {
|
||||
return executeBulkMutation(
|
||||
operation as Extract<PeopleMutationOperation, `users.${string}`>,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (operation === "guild.disband")
|
||||
return executeGuildDisband(input, context);
|
||||
if (operation === "application.decide") {
|
||||
return executeApplicationDecision(input, context);
|
||||
}
|
||||
if (operation === "team.change") return executeTeamChange(input, context);
|
||||
if (operation === "ip.action") return executeIpAction(input, context);
|
||||
if (operation === "vpn.configure") {
|
||||
return executeVpnConfiguration(input, context);
|
||||
}
|
||||
return executeWordFilterUpdate(input, context);
|
||||
},
|
||||
};
|
||||
|
||||
export const peopleMutationService = createPeopleMutationService(
|
||||
productionPeopleMutationAdapter,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join, posix } from "node:path";
|
||||
import { createElement, type ReactElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
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 { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { discoverLegacyPages } from "../migration/discover-legacy-pages";
|
||||
@@ -18,7 +19,82 @@ const SERVER_CAPABILITY_CONTEXT =
|
||||
"src/features/housekeeping/foundation/server-capability-context.ts";
|
||||
const PERMISSIONS_ADAPTER = "src/lib/permissions";
|
||||
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
|
||||
const approvedSystemRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/people/commands/community-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/commands/user-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/community.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/community",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/multi-accounts.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/staff.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/staff",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/user-detail.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/user-edit.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/users.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||
new Set([
|
||||
"src/lib/auth",
|
||||
"src/lib/auth/password",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
"mysql2",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/manifest.ts",
|
||||
new Set(["src/features/housekeeping/domains/people/routes"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/route-handlers.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/community",
|
||||
"src/features/housekeeping/domains/people/pages/multi-accounts",
|
||||
"src/features/housekeeping/domains/people/pages/staff",
|
||||
"src/features/housekeeping/domains/people/pages/user-detail",
|
||||
"src/features/housekeeping/domains/people/pages/user-edit",
|
||||
"src/features/housekeeping/domains/people/pages/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/system/commands/system-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/system/services/mutations"]),
|
||||
@@ -127,12 +203,17 @@ const approvedSystemRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/foundation/commands/bootstrap.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/commands/community-commands",
|
||||
"src/features/housekeeping/domains/people/commands/user-commands",
|
||||
"src/features/housekeeping/domains/system/commands/system-commands",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/route-handlers.ts",
|
||||
new Set(["src/features/housekeeping/domains/system/route-handlers"]),
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/route-handlers",
|
||||
"src/features/housekeeping/domains/system/route-handlers",
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const forbiddenModuleRoots = [
|
||||
@@ -476,11 +557,11 @@ function isAllowedPermissionSetTypeImport(
|
||||
);
|
||||
}
|
||||
|
||||
function isApprovedSystemRuntimeImport(
|
||||
function isApprovedRuntimeImport(
|
||||
canonical: CanonicalModuleSpecifier,
|
||||
sourceFile: string,
|
||||
): boolean {
|
||||
const allowed = approvedSystemRuntimeImports.get(sourceFile);
|
||||
const allowed = approvedRuntimeImports.get(sourceFile);
|
||||
return (
|
||||
allowed !== undefined &&
|
||||
canonical.candidates.some((candidate) => allowed.has(candidate))
|
||||
@@ -499,7 +580,7 @@ function findHousekeepingImportBoundaryViolations(
|
||||
if (canonical.violation) violations.push(canonical.violation);
|
||||
if (isAllowedPermissionSetTypeImport(access, canonical, sourceFile))
|
||||
continue;
|
||||
if (isApprovedSystemRuntimeImport(canonical, sourceFile)) continue;
|
||||
if (isApprovedRuntimeImport(canonical, sourceFile)) continue;
|
||||
const forbiddenPath = canonical.candidates.find((candidate) =>
|
||||
isForbiddenModulePath(candidate, sourceFile),
|
||||
);
|
||||
@@ -528,7 +609,13 @@ describe("housekeeping runtime import boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("allows only the approved System vertical runtime edges", () => {
|
||||
it("allows only approved domain vertical runtime edges", () => {
|
||||
expect(
|
||||
findHousekeepingImportBoundaryViolations(
|
||||
'import { peopleMutationService } from "../services/mutations";',
|
||||
"src/features/housekeeping/domains/people/commands/user-commands.ts",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findHousekeepingImportBoundaryViolations(
|
||||
'import { systemMutationService } from "../services/mutations";',
|
||||
@@ -718,7 +805,7 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the real six-domain registry with only the System routes enabled", () => {
|
||||
it("creates the real six-domain registry with People primary and System routes enabled", () => {
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
|
||||
expect(registry.domains.map((domain) => domain.id)).toEqual([
|
||||
@@ -731,9 +818,14 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
]);
|
||||
expect(
|
||||
registry.domains
|
||||
.filter((domain) => domain.id !== "system")
|
||||
.filter((domain) => !["people", "system"].includes(domain.id))
|
||||
.every((domain) => domain.routes.length === 0),
|
||||
).toBe(true);
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "people")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(PEOPLE_PRIMARY_ROUTE_IDS);
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "system")
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user