feat: comprehensive CMS improvements
- Fix DOMPurify SSR crash (use isomorphic-dompurify) - Fix SanitizedHtml to sanitize by default - Add auth guards to studio/catalog maintenance pages - Add update/edit to vouchers CRUD - Add update/edit to rare-values CRUD - Add approve workflow to applications page - Add edit form to guilds detail page - Add SEO metadata to all public pages (21 pages) - Fix mobile nav accessibility (focus trap, aria attributes) - Fix missing labels and table accessibility - Add dynamic imports for heavy client components (6 components) - Fix silent error swallowing (40+ locations) - Add content scheduling for articles (publishAt, status) - Wire up 12 missing webhook notification triggers - Add global search to admin panel - Add bulk actions to admin users table - Fix JSON formatting and a11y issues
This commit is contained in:
1 parent
b810b16672
commit
30c95b1a5c
78 files changed
+2252
-134
No files matched your search
@@ -22,3 +22,19 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/applications");
|
||||
}
|
||||
|
||||
export async function approveApplication(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
|
||||
}
|
||||
|
||||
revalidatePath("/admin/applications");
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
@@ -43,12 +44,16 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
const status = String(formData.get("status") ?? "published");
|
||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const publishAt = rawPublishAt ? new Date(rawPublishAt) : null;
|
||||
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
||||
await db.insert(WebsiteArticles).values({
|
||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||
slug,
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
@@ -56,6 +61,16 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: status || "published",
|
||||
publishAt,
|
||||
publishedAt: status === "published" ? now : null,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: title,
|
||||
details: slug,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
@@ -70,7 +85,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
const status = String(formData.get("status") ?? "published");
|
||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||
try {
|
||||
const publishAt = rawPublishAt ? new Date(rawPublishAt) : null;
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
@@ -90,6 +108,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
status,
|
||||
publishAt,
|
||||
publishedAt: status === "published" ? new Date() : undefined,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
@@ -101,8 +122,13 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const [article] = await db
|
||||
.select({ title: WebsiteArticles.title })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1);
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
@@ -113,6 +139,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "news_delete",
|
||||
actor: staff.username,
|
||||
target: article?.title ?? String(id),
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import {
|
||||
requirePermission,
|
||||
requirePermissionRateLimited,
|
||||
} from "@/lib/admin/guard";
|
||||
import {
|
||||
db,
|
||||
GuildForumViews,
|
||||
@@ -63,3 +66,47 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
});
|
||||
revalidatePath("/admin/guilds");
|
||||
}
|
||||
|
||||
export async function updateGuild(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 50);
|
||||
const description = String(formData.get("description") ?? "")
|
||||
.trim()
|
||||
.slice(0, 250);
|
||||
const state = Number(formData.get("state"));
|
||||
const forum = String(formData.get("forum") ?? "0");
|
||||
const readForum = String(formData.get("readForum") ?? "EVERYONE");
|
||||
const postMessages = String(formData.get("postMessages") ?? "EVERYONE");
|
||||
const postThreads = String(formData.get("postThreads") ?? "EVERYONE");
|
||||
const modForum = String(formData.get("modForum") ?? "ADMINS");
|
||||
|
||||
await db
|
||||
.update(Guilds)
|
||||
.set({
|
||||
name,
|
||||
description,
|
||||
state,
|
||||
forum,
|
||||
readForum,
|
||||
postMessages,
|
||||
postThreads,
|
||||
modForum,
|
||||
})
|
||||
.where(eq(Guilds.id, id));
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "guild_update",
|
||||
description: `Updated guild #${id}`,
|
||||
targetType: "guild",
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/guilds");
|
||||
revalidatePath(`/admin/guilds/${id}`);
|
||||
}
|
||||
@@ -115,3 +115,88 @@ export async function deleteValue(formData: FormData): Promise<void> {
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function updateCategory(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const priorityRaw = Number(formData.get("priority"));
|
||||
const priority =
|
||||
Number.isFinite(priorityRaw) && priorityRaw > 0
|
||||
? Math.floor(priorityRaw)
|
||||
: 1;
|
||||
if (!name || !badge) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteRareValueCategories)
|
||||
.set({ name, badge, priority })
|
||||
.where(eq(WebsiteRareValueCategories.id, id));
|
||||
} catch {
|
||||
// Unique name collision or DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function updateValue(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name || !furnitureIcon) return;
|
||||
|
||||
const itemIdRaw = Number(formData.get("itemId"));
|
||||
const itemId =
|
||||
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyType =
|
||||
String(formData.get("currencyType") ?? "diamonds")
|
||||
.trim()
|
||||
.slice(0, 255) || "diamonds";
|
||||
|
||||
try {
|
||||
const sets: Record<string, unknown> = {
|
||||
name,
|
||||
itemId,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
};
|
||||
if (categoryId) sets.categoryId = categoryId;
|
||||
await db
|
||||
.update(WebsiteRareValues)
|
||||
.set(sets)
|
||||
.where(eq(WebsiteRareValues.id, id));
|
||||
} catch {
|
||||
// DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
@@ -83,3 +83,58 @@ export async function deleteVoucher(input: {
|
||||
return actionError("Could not delete voucher");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVoucher(input: {
|
||||
id: string;
|
||||
code: string;
|
||||
amount: number;
|
||||
maxUses: number;
|
||||
expiresAt?: string;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
|
||||
const id = positiveBigInt(String(input.id ?? "").trim());
|
||||
if (!id) return actionError("Missing voucher id");
|
||||
|
||||
const code = String(input.code ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const amount = Number(input.amount);
|
||||
const maxUsesRaw = Number(input.maxUses);
|
||||
const maxUses =
|
||||
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||
|
||||
if (!code || !(amount > 0)) {
|
||||
return actionError("Code and a positive amount are required");
|
||||
}
|
||||
|
||||
let expiresAt: Date | null = null;
|
||||
const expiresRaw = String(input.expiresAt ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (expiresRaw) {
|
||||
const parsed = new Date(expiresRaw);
|
||||
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteShopVouchers)
|
||||
.set({
|
||||
code,
|
||||
amount: Math.floor(amount),
|
||||
maxUses,
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteShopVouchers.id, id));
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_update_failed", error, {
|
||||
voucherId: String(id),
|
||||
});
|
||||
return actionError("Could not update voucher (code may already exist)");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -127,7 +128,12 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("drawbadge.give_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import {
|
||||
createEventSchema,
|
||||
eventPrizeSchema,
|
||||
@@ -120,6 +121,11 @@ export const createEvent = adminAction(
|
||||
targetId: eventId,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
notify({
|
||||
action: "event_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: ctx.data.title,
|
||||
});
|
||||
return actionOk({ id: eventId });
|
||||
},
|
||||
);
|
||||
@@ -155,6 +161,11 @@ export const updateEvent = adminAction(
|
||||
before: { title: existing.title, status: existing.status },
|
||||
after: data,
|
||||
});
|
||||
notify({
|
||||
action: "event_update",
|
||||
actor: ctx.session.user.username,
|
||||
target: data.title ?? existing.title,
|
||||
});
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
const ticketSchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
@@ -160,6 +161,15 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "created";
|
||||
|
||||
const sessionUser = session?.user;
|
||||
if (sessionUser?.name) {
|
||||
notify({
|
||||
action: "ticket_create",
|
||||
actor: sessionUser.name,
|
||||
target: ticketTitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from "@/env";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { db, PasswordReset, User } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
|
||||
@@ -123,7 +124,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
await db
|
||||
.delete(PasswordReset)
|
||||
.where(eq(PasswordReset.email, email))
|
||||
.catch(() => {});
|
||||
.catch((error) =>
|
||||
logServerError("password.reset_delete_tokens_failed", error, {
|
||||
email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import {
|
||||
createPollSchema,
|
||||
pollQuestionSchema,
|
||||
@@ -38,6 +39,11 @@ export const createPoll = adminAction(
|
||||
targetId: pollId,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
notify({
|
||||
action: "poll_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: ctx.data.title,
|
||||
});
|
||||
return actionOk({ id: pollId });
|
||||
},
|
||||
);
|
||||
|
||||
+18
-1
@@ -7,6 +7,7 @@ import { db, Items, Rooms } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
@@ -75,11 +76,17 @@ export async function roomRconAction({
|
||||
roomId: number;
|
||||
action: string;
|
||||
}) {
|
||||
await requirePermission(PERMS.ROOMS_EDIT);
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
if (action === "reload") {
|
||||
await rcon.send("reloadroom", { room_id: roomId });
|
||||
} else if (action === "kick") {
|
||||
await rcon.send("kickall", { room_id: roomId });
|
||||
notify({
|
||||
action: "kick",
|
||||
actor: staff.username,
|
||||
target: String(roomId),
|
||||
details: action,
|
||||
});
|
||||
} else if (action === "lock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||
} else if (action === "unlock") {
|
||||
@@ -89,6 +96,11 @@ export async function roomRconAction({
|
||||
|
||||
export async function deleteRoom({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||
const [room] = await db
|
||||
.select({ name: Rooms.name })
|
||||
.from(Rooms)
|
||||
.where(eq(Rooms.id, id))
|
||||
.limit(1);
|
||||
await db.delete(Rooms).where(eq(Rooms.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -97,6 +109,11 @@ export async function deleteRoom({ id }: { id: number }) {
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
notify({
|
||||
action: "room_delete",
|
||||
actor: staff.username,
|
||||
target: room?.name ?? `#${id}`,
|
||||
});
|
||||
revalidatePath("/admin/rooms");
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
||||
@@ -183,7 +184,12 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
);
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("shop.give_badge_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
outcome = "bought";
|
||||
|
||||
+16
-2
@@ -97,7 +97,7 @@ export const createUser = adminAction(
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
action: "user_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: username,
|
||||
targetId: user.id,
|
||||
@@ -450,6 +450,13 @@ export const muteUser = adminAction(
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "hotel_alert",
|
||||
actor: ctx.session.user.username,
|
||||
target: _target.username,
|
||||
details: "Muted",
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -463,7 +470,7 @@ const unmuteSchema = z.object({
|
||||
export const unmuteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
||||
async (ctx) => {
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const target = 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?");
|
||||
@@ -475,6 +482,13 @@ export const unmuteUser = adminAction(
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "hotel_alert",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
details: "Unmuted",
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user