feat(housekeeping): deliver system vertical
This commit is contained in:
1 parent
0117b45d74
commit
3788ecd9f1
33 files changed
+4548
-423
No files matched your search
@@ -7,7 +7,7 @@ import { sendHotelAlert } from "./admin-alerts";
|
||||
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
||||
PERMS: { NOTIFICATIONS_EDIT: "admin.notifications.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
|
||||
+34
-10
@@ -1,11 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
type SystemMutationContext,
|
||||
systemMutationService,
|
||||
} from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { AlertLogs, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
function grantedMutationContext(
|
||||
staff: { id: number; rank: number; username: string },
|
||||
permission: string,
|
||||
): SystemMutationContext {
|
||||
const matches = (slug: string) => slug === permission;
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(staff, {
|
||||
isSuperAdmin: false,
|
||||
has: matches,
|
||||
hasAny: (...slugs) => slugs.some(matches),
|
||||
hasAll: (...slugs) => slugs.every(matches),
|
||||
}),
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a hotel-wide alert to every online user via RCON.
|
||||
@@ -14,7 +33,7 @@ import { rcon } from "@/lib/services/rcon";
|
||||
* `message` payload. Staff-gated; the message is trimmed/bounded before send.
|
||||
*/
|
||||
export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NOTIFICATIONS_EDIT);
|
||||
const staff = await requirePermission(PERMS.NOTIFICATIONS_EDIT);
|
||||
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -23,7 +42,11 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
if (!message) return;
|
||||
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
await systemMutationService.execute(
|
||||
grantedMutationContext(staff, PERMS.NOTIFICATIONS_EDIT),
|
||||
"operations.alert.broadcast",
|
||||
{ message },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort delivery (dead socket / emulator offline) — never 500 the
|
||||
// admin page. The emulator writes its own alert_logs row on receipt.
|
||||
@@ -34,12 +57,13 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
|
||||
/** Mark every unread ops alert as read. */
|
||||
export async function markAllAlertsRead(): Promise<void> {
|
||||
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
||||
const staff = await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
||||
try {
|
||||
await db
|
||||
.update(AlertLogs)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(eq(AlertLogs.isRead, false));
|
||||
await systemMutationService.execute(
|
||||
grantedMutationContext(staff, PERMS.NOTIFICATIONS_VIEW),
|
||||
"operations.alerts.mark-read",
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
|
||||
@@ -10,8 +12,40 @@ import { PERMS } from "@/lib/permissions";
|
||||
// Both tables are emulator-owned; we only ever read/update existing rows or add new
|
||||
// keys via upsert. We never migrate or drop them.
|
||||
|
||||
function mutationContext(staff: {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}) {
|
||||
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(staff, {
|
||||
isSuperAdmin: false,
|
||||
has: matches,
|
||||
hasAny: (...slugs: string[]) => slugs.some(matches),
|
||||
hasAll: (...slugs: string[]) => slugs.every(matches),
|
||||
}),
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireMutation(
|
||||
staff: { id: number; rank: number; username: string },
|
||||
operation:
|
||||
| "configuration.emulator-setting.update"
|
||||
| "configuration.emulator-text.update",
|
||||
input: { key: string; value: string },
|
||||
): Promise<void> {
|
||||
const result = await systemMutationService.execute(
|
||||
mutationContext(staff),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
}
|
||||
|
||||
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
@@ -20,15 +54,15 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.slice(0, 512);
|
||||
if (!key) return;
|
||||
await db
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await requireMutation(staff, "configuration.emulator-setting.update", {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
|
||||
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
@@ -37,9 +71,9 @@ export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.slice(0, 4096);
|
||||
if (!key) return;
|
||||
await db
|
||||
.insert(EmulatorTexts)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await requireMutation(staff, "configuration.emulator-text.update", {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
|
||||
// AtomCMS's MaintenanceToggle Livewire component):
|
||||
@@ -14,32 +15,25 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
// The Laravel login flow reads these via setting() to gate non-staff logins
|
||||
// while maintenance is on, so the website_settings keys are the source of truth.
|
||||
|
||||
const KEY_ENABLED = "maintenance_enabled";
|
||||
const KEY_MESSAGE = "maintenance_message";
|
||||
const KEY_MIN_RANK = "min_maintenance_login_rank";
|
||||
|
||||
const COMMENTS: Record<string, string> = {
|
||||
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
|
||||
[KEY_MESSAGE]:
|
||||
"The maintenance message displayed to users while maintenance is activated",
|
||||
[KEY_MIN_RANK]:
|
||||
"The minimum rank required to login to the hotel during maintenance",
|
||||
};
|
||||
|
||||
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
comment: COMMENTS[key] ?? null,
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
function mutationContext(staff: {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}) {
|
||||
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(staff, {
|
||||
isSuperAdmin: false,
|
||||
has: matches,
|
||||
hasAny: (...slugs: string[]) => slugs.some(matches),
|
||||
hasAll: (...slugs: string[]) => slugs.every(matches),
|
||||
}),
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
|
||||
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
|
||||
// emulator/Laravel side expects.
|
||||
@@ -56,10 +50,15 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
const minRank =
|
||||
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
|
||||
|
||||
await upsertSetting(KEY_ENABLED, enabled);
|
||||
await upsertSetting(KEY_MESSAGE, message);
|
||||
await upsertSetting(KEY_MIN_RANK, String(minRank));
|
||||
|
||||
siteSettings.reload();
|
||||
const result = await systemMutationService.execute(
|
||||
mutationContext(staff),
|
||||
"operations.maintenance.update",
|
||||
{
|
||||
enabled: enabled === "1",
|
||||
message,
|
||||
minimumLoginRank: minRank,
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
revalidatePath("/admin/maintenance");
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||
import {
|
||||
type SystemMutationContext,
|
||||
type SystemMutationOperation,
|
||||
systemMutationService,
|
||||
} from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import type { AdminActionContext } from "@/lib/foundation/types";
|
||||
import {
|
||||
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
||||
|
||||
@@ -25,11 +28,47 @@ function normalizeSettingValue(key: string, value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function bustGamedataCachesIfNeeded(key: string): void {
|
||||
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
|
||||
clearOfficialHabboFurnidataCache();
|
||||
clearBadgeCache();
|
||||
}
|
||||
function actionMutationContext(
|
||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
): SystemMutationContext {
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(
|
||||
{
|
||||
id: Number(ctx.session.user.id),
|
||||
rank: Number(ctx.session.user.rank),
|
||||
username: ctx.session.user.username,
|
||||
},
|
||||
ctx.permissions,
|
||||
),
|
||||
correlationId: String(ctx.requestId),
|
||||
};
|
||||
}
|
||||
|
||||
function checkedMutationContext(staff: {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}): SystemMutationContext {
|
||||
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(staff, {
|
||||
isSuperAdmin: false,
|
||||
has: matches,
|
||||
hasAny: (...slugs) => slugs.some(matches),
|
||||
hasAll: (...slugs) => slugs.every(matches),
|
||||
}),
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(
|
||||
context: SystemMutationContext,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<unknown> {
|
||||
const result = await systemMutationService.execute(context, operation, input);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const saveManagedSchema = z.object({
|
||||
@@ -47,27 +86,19 @@ export const saveManagedSettings = adminAction(
|
||||
const entries = Object.entries(ctx.data.settings)
|
||||
.filter(([key]) => managedKeySet.has(key))
|
||||
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
|
||||
await Promise.all(
|
||||
entries.map(([key, value]) =>
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } }),
|
||||
),
|
||||
);
|
||||
await siteSettings.reload();
|
||||
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
|
||||
clearOfficialHabboFurnidataCache();
|
||||
clearBadgeCache();
|
||||
}
|
||||
const mutation = (await runMutation(
|
||||
actionMutationContext(ctx),
|
||||
"configuration.settings.save",
|
||||
{ settings: Object.fromEntries(entries) },
|
||||
)) as { saved: number };
|
||||
revalidatePath("/admin/settings");
|
||||
revalidatePath("/admin/catalog");
|
||||
return actionOk({ saved: entries.length });
|
||||
return actionOk({ saved: mutation.saved });
|
||||
},
|
||||
);
|
||||
|
||||
export async function updateSetting(formData: FormData): Promise<void> {
|
||||
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
@@ -76,17 +107,16 @@ export async function updateSetting(formData: FormData): Promise<void> {
|
||||
String(formData.get("value") ?? "").normalize("NFC"),
|
||||
);
|
||||
if (!key) return;
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
await runMutation(
|
||||
checkedMutationContext(staff),
|
||||
"configuration.setting.update",
|
||||
{ key, value },
|
||||
);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
export async function createSetting(formData: FormData): Promise<void> {
|
||||
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
@@ -100,23 +130,24 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
await runMutation(
|
||||
checkedMutationContext(staff),
|
||||
"configuration.setting.create",
|
||||
{ key, value, comment },
|
||||
);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
|
||||
await siteSettings.reload();
|
||||
bustGamedataCachesIfNeeded(key);
|
||||
await runMutation(
|
||||
checkedMutationContext(staff),
|
||||
"configuration.setting.delete",
|
||||
{ key },
|
||||
);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
+114
-106
@@ -1,48 +1,96 @@
|
||||
"use server";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db, User } from "@/lib/db";
|
||||
import {
|
||||
type SystemMutationContext,
|
||||
type SystemMutationOperation,
|
||||
systemMutationService,
|
||||
} from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import type { AdminActionContext } from "@/lib/foundation/types";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const PATH = "/admin/commandocentrum";
|
||||
|
||||
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||
|
||||
async function requireRconOk(ok: boolean): Promise<void> {
|
||||
if (!ok) throw new ActionError(RCON_FAIL);
|
||||
function mutationContext(
|
||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
): SystemMutationContext {
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(
|
||||
{
|
||||
id: Number(ctx.session.user.id),
|
||||
rank: Number(ctx.session.user.rank),
|
||||
username: ctx.session.user.username,
|
||||
},
|
||||
ctx.permissions,
|
||||
),
|
||||
correlationId: String(ctx.requestId),
|
||||
};
|
||||
}
|
||||
|
||||
async function runRconMutation(
|
||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<void> {
|
||||
const result = await systemMutationService.execute(
|
||||
mutationContext(ctx),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (result.ok) return;
|
||||
if (result.error.messageKey === "errors.housekeeping.system.userNotFound") {
|
||||
throw new ActionError("User not found");
|
||||
}
|
||||
if (result.error.messageKey === "errors.housekeeping.system.rankNotFound") {
|
||||
throw new ActionError("Rank does not exist");
|
||||
}
|
||||
if (
|
||||
result.error.messageKey ===
|
||||
"errors.housekeeping.system.cannotChangePeerRank"
|
||||
) {
|
||||
throw new ActionError("Cannot change rank of a user at or above your rank");
|
||||
}
|
||||
if (
|
||||
result.error.messageKey ===
|
||||
"errors.housekeeping.system.cannotAssignPeerRank"
|
||||
) {
|
||||
throw new ActionError("Cannot set a rank equal to or above your own");
|
||||
}
|
||||
throw new ActionError(RCON_FAIL);
|
||||
}
|
||||
|
||||
function revalidate(): void {
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||
export const updateCatalog = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async () => {
|
||||
await requireRconOk(await rcon.updateCatalog());
|
||||
revalidatePath(PATH);
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.update-catalog", {});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||
export const updateWordFilter = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async () => {
|
||||
await requireRconOk(await rcon.updateWordFilter());
|
||||
revalidatePath(PATH);
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.update-word-filter", {});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||
export const updateNavigator = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async () => {
|
||||
await requireRconOk(await rcon.send("updatenavigator", null));
|
||||
revalidatePath(PATH);
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.update-navigator", {});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -51,13 +99,13 @@ const hotelAlertSchema = z.object({
|
||||
message: z.string().trim().min(1).max(512),
|
||||
});
|
||||
|
||||
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||
export const hotelAlert = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
|
||||
async (ctx) => {
|
||||
const message = ctx.data.message.normalize("NFC");
|
||||
await requireRconOk(await rcon.send("hotelalert", { message }));
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.hotel-alert", {
|
||||
message: ctx.data.message.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -67,13 +115,14 @@ const disconnectSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
const username = ctx.data.username.normalize("NFC");
|
||||
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.disconnect-user", {
|
||||
userId: ctx.data.userId,
|
||||
username: ctx.data.username.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -83,13 +132,14 @@ const alertUserSchema = z.object({
|
||||
message: z.string().trim().min(1).max(512),
|
||||
});
|
||||
|
||||
/** Send an alert to a specific user (rcon: alertuser). */
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
const message = ctx.data.message.normalize("NFC");
|
||||
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.alert-user", {
|
||||
userId: ctx.data.userId,
|
||||
message: ctx.data.message.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -99,14 +149,11 @@ const forwardUserSchema = z.object({
|
||||
roomId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Forward a user to a specific room (rcon: forwarduser). */
|
||||
export const forwardUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.forward-user", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -116,14 +163,14 @@ const giveCreditsSchema = z.object({
|
||||
credits: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Give credits to a user (rcon: givecredits). */
|
||||
export const giveCredits = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.give-credits", {
|
||||
userId: ctx.data.userId,
|
||||
amount: ctx.data.credits,
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -133,26 +180,20 @@ const giveAmountSchema = z.object({
|
||||
amount: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
||||
export const giveDuckets = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.give-duckets", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
||||
export const giveDiamonds = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
await requireRconOk(
|
||||
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.give-diamonds", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -162,13 +203,14 @@ const giveBadgeSchema = z.object({
|
||||
badge: z.string().trim().min(1).max(32),
|
||||
});
|
||||
|
||||
/** Give a badge to a user (rcon: givebadge). */
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
const badge = ctx.data.badge.normalize("NFC");
|
||||
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.give-badge", {
|
||||
userId: ctx.data.userId,
|
||||
badge: ctx.data.badge.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -178,13 +220,14 @@ const setMottoSchema = z.object({
|
||||
motto: z.string().trim().min(1).max(127),
|
||||
});
|
||||
|
||||
/** Set a user's motto (rcon: setmotto). */
|
||||
export const setMotto = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
||||
async (ctx) => {
|
||||
const motto = ctx.data.motto.normalize("NFC");
|
||||
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.set-motto", {
|
||||
userId: ctx.data.userId,
|
||||
motto: ctx.data.motto.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -194,47 +237,11 @@ const setRankSchema = z.object({
|
||||
rank: z.coerce.number().int().min(1).max(9999),
|
||||
});
|
||||
|
||||
/** Set a user's rank (rcon: setrank). */
|
||||
export const setRank = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||
async (ctx) => {
|
||||
const staffRank = Number(ctx.session.user.rank);
|
||||
const isSuper = ctx.permissions.isSuperAdmin;
|
||||
const [target] = await db
|
||||
.select({ rank: User.rank })
|
||||
.from(User)
|
||||
.where(eq(User.id, ctx.data.userId))
|
||||
.limit(1);
|
||||
if (!target) throw new ActionError("User not found");
|
||||
|
||||
let rankExists: { id: number }[] = [];
|
||||
try {
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
|
||||
);
|
||||
rankExists = rows as unknown as { id: number }[];
|
||||
} catch {
|
||||
rankExists = [];
|
||||
}
|
||||
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
|
||||
|
||||
if (!isSuper) {
|
||||
if (target.rank >= staffRank) {
|
||||
throw new ActionError(
|
||||
"Cannot change rank of a user at or above your rank",
|
||||
);
|
||||
}
|
||||
if (ctx.data.rank >= staffRank) {
|
||||
throw new ActionError("Cannot set a rank equal to or above your own");
|
||||
}
|
||||
}
|
||||
|
||||
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
|
||||
await db
|
||||
.update(User)
|
||||
.set({ rank: ctx.data.rank })
|
||||
.where(eq(User.id, ctx.data.userId));
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.set-rank", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -244,13 +251,14 @@ const executeCommandSchema = z.object({
|
||||
command: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
/** Execute a command as a user (rcon: executecommand). */
|
||||
export const executeCommand = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||
async (ctx) => {
|
||||
const command = ctx.data.command.normalize("NFC");
|
||||
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.execute-command", {
|
||||
userId: ctx.data.userId,
|
||||
command: ctx.data.command.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -261,15 +269,15 @@ const sendGiftSchema = z.object({
|
||||
message: z.string().trim().max(255).optional().default("Here is a gift."),
|
||||
});
|
||||
|
||||
/** Send a gift to a user (rcon: sendgift). */
|
||||
export const sendGift = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
|
||||
async (ctx) => {
|
||||
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
|
||||
await requireRconOk(
|
||||
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
|
||||
);
|
||||
revalidatePath(PATH);
|
||||
await runRconMutation(ctx, "rcon.send-gift", {
|
||||
userId: ctx.data.userId,
|
||||
itemId: ctx.data.itemId,
|
||||
message: ctx.data.message.trim().slice(0, 255) || "Here is a gift.",
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+59
-208
@@ -1,27 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AclModelPermission,
|
||||
AclModelRole,
|
||||
AclPermission,
|
||||
AclRole,
|
||||
db,
|
||||
User,
|
||||
} from "@/lib/db";
|
||||
type SystemMutationContext,
|
||||
type SystemMutationOperation,
|
||||
systemMutationService,
|
||||
} from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import type { AdminActionContext } from "@/lib/foundation/types";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import {
|
||||
createEmulatorRank,
|
||||
deleteEmulatorRank,
|
||||
updateEmulatorRank,
|
||||
} from "@/lib/services/permission-ranks";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
function mutationContext(
|
||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
): SystemMutationContext {
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(
|
||||
{
|
||||
id: Number(ctx.session.user.id),
|
||||
rank: Number(ctx.session.user.rank),
|
||||
username: ctx.session.user.username,
|
||||
},
|
||||
ctx.permissions,
|
||||
),
|
||||
correlationId: String(ctx.requestId),
|
||||
};
|
||||
}
|
||||
|
||||
async function runAccessMutation(
|
||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<unknown> {
|
||||
const result = await systemMutationService.execute(
|
||||
mutationContext(ctx),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (result.ok) return result.data;
|
||||
if (result.error.messageKey === "errors.housekeeping.system.rankInUse") {
|
||||
const users = Number(result.error.fieldErrors?.rank?.[0]);
|
||||
if (Number.isInteger(users) && users > 0) {
|
||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
}
|
||||
}
|
||||
if (result.error.messageKey === "errors.housekeeping.system.roleNotFound") {
|
||||
throw new ActionError("Role not found");
|
||||
}
|
||||
throw new ActionError(result.error.messageKey);
|
||||
}
|
||||
|
||||
const createRankSchema = z.object({
|
||||
rank_name: z.string().trim().min(1).max(25),
|
||||
@@ -31,25 +60,12 @@ const createRankSchema = z.object({
|
||||
export const createRank = adminAction(
|
||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const id = await createEmulatorRank(db, ctx.data);
|
||||
await db
|
||||
.insert(AclRole)
|
||||
.values({
|
||||
slug: `rank_${id}`,
|
||||
title: ctx.data.rank_name,
|
||||
description: "CMS role synchronized from permission_ranks",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_create",
|
||||
description: `Created rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
const result = (await runAccessMutation(ctx, "access.rank.create", {
|
||||
name: ctx.data.rank_name,
|
||||
level: ctx.data.level,
|
||||
})) as { id: number };
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk({ id });
|
||||
return actionOk({ id: result.id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -58,41 +74,7 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteRank = adminAction(
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const [userCount] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.rank, ctx.data.id));
|
||||
const users = userCount?.total ?? 0;
|
||||
if (users > 0)
|
||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
|
||||
.limit(1);
|
||||
await deleteEmulatorRank(db, ctx.data.id);
|
||||
if (role) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
and(
|
||||
eq(AclModelPermission.modelId, role.id),
|
||||
eq(AclModelPermission.modelType, "Role"),
|
||||
),
|
||||
);
|
||||
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
|
||||
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
|
||||
});
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_delete",
|
||||
description: `Deleted rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
await runAccessMutation(ctx, "access.rank.delete", ctx.data);
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
@@ -106,21 +88,7 @@ const saveRankSchema = z.object({
|
||||
export const saveRank = adminAction(
|
||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
|
||||
if (typeof ctx.data.fields.rank_name === "string") {
|
||||
await db
|
||||
.update(AclRole)
|
||||
.set({ title: ctx.data.fields.rank_name })
|
||||
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_update",
|
||||
description: `Updated rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
await runAccessMutation(ctx, "access.rank.update", ctx.data);
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
@@ -134,138 +102,21 @@ const setCmsPermsSchema = z.object({
|
||||
export const setCmsPermissions = adminAction(
|
||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const [role] = await db
|
||||
.select({ id: AclRole.id, slug: AclRole.slug })
|
||||
.from(AclRole)
|
||||
.where(eq(AclRole.id, ctx.data.roleId))
|
||||
.limit(1);
|
||||
if (!role) throw new ActionError("Role not found");
|
||||
const permissions = await db
|
||||
.select({ id: AclPermission.id })
|
||||
.from(AclPermission)
|
||||
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(AclModelPermission)
|
||||
.where(
|
||||
and(
|
||||
eq(AclModelPermission.modelId, role.id),
|
||||
eq(AclModelPermission.modelType, "Role"),
|
||||
),
|
||||
);
|
||||
if (permissions.length) {
|
||||
await tx.insert(AclModelPermission).values(
|
||||
permissions.map((permission) => ({
|
||||
modelId: role.id,
|
||||
modelType: "Role",
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "acl_role_permissions_update",
|
||||
description: `Updated ${permissions.length} permissions for ${role.slug}`,
|
||||
targetType: "acl_role",
|
||||
targetId: role.id,
|
||||
});
|
||||
await runAccessMutation(ctx, "access.permissions.update", ctx.data);
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-apply the same grant repair as migration 0018:
|
||||
* - ranks with admin.dashboard get all admin.*
|
||||
* - ranks >= 6 get admin.*.view + dashboard
|
||||
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
|
||||
*/
|
||||
export const repairAdminNavAclGrants = adminAction(
|
||||
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const [dashboardFillResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`acl_roles\` ar
|
||||
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp
|
||||
JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND apdash.slug = 'admin.dashboard'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp2
|
||||
WHERE amp2.model_type = 'Role'
|
||||
AND amp2.model_id = ar.id
|
||||
AND amp2.permission_id = ap.id
|
||||
)
|
||||
`);
|
||||
|
||||
const [midRankViewsResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`permission_ranks\` pr
|
||||
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN \`acl_permissions\` ap ON (
|
||||
ap.slug = 'admin.dashboard'
|
||||
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
|
||||
)
|
||||
WHERE pr.id >= 6
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
)
|
||||
`);
|
||||
|
||||
const [highRankToolsResult] = await db.execute(sql`
|
||||
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||
SELECT 'Role', ar.id, ap.id
|
||||
FROM \`permission_ranks\` pr
|
||||
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||
JOIN \`acl_permissions\` ap ON (
|
||||
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
|
||||
OR ap.slug IN (
|
||||
'admin.permissions.manage',
|
||||
'admin.rcon.execute',
|
||||
'admin.assets.import',
|
||||
'admin.export',
|
||||
'admin.analytics.export',
|
||||
'admin.users.ban',
|
||||
'admin.users.reset_password',
|
||||
'admin.room.delete'
|
||||
)
|
||||
)
|
||||
WHERE pr.id >= 7
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`acl_model_permissions\` amp
|
||||
WHERE amp.model_type = 'Role'
|
||||
AND amp.model_id = ar.id
|
||||
AND amp.permission_id = ap.id
|
||||
)
|
||||
`);
|
||||
|
||||
const inserted =
|
||||
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
|
||||
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
|
||||
Number((highRankToolsResult as ResultSetHeader).affectedRows);
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "acl_nav_grants_repair",
|
||||
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
|
||||
targetType: "acl",
|
||||
targetId: 0,
|
||||
});
|
||||
const result = (await runAccessMutation(
|
||||
ctx,
|
||||
"access.permissions.repair",
|
||||
{},
|
||||
)) as { inserted: number };
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk({ inserted });
|
||||
return actionOk({ inserted: result.inserted });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user