Harden CMS security and theme contrast

This commit is contained in:
Simo committed 2026-07-11 20:27:20 +02:00
1 parent 2465ff2170
commit 4a1e1115b3
57 files changed
+1023 -231

No files matched your search

+3 -3
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
// CRUD for website advertisements (website_ads). Emulator does not own this
// table; it only stores an image URL rendered in the site layout/widgets.
@@ -68,9 +69,8 @@ export async function updateAd(formData: FormData): Promise<void> {
export async function deleteAd(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await prisma.websiteAds.delete({ where: { id } });
+3 -9
View File
@@ -3,18 +3,12 @@
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data";
export async function dismissApplication(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await prisma.websiteStaffApplications.delete({ where: { id } });
+1
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { slugify } from "@/lib/format";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data";
async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
+3 -8
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
@@ -61,14 +62,8 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await prisma.emailTemplates.delete({ where: { id } });
revalidatePath("/admin/email-templates");
}
+5 -6
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
// is a titled content block with an optional image and call-to-action button.
@@ -72,9 +73,8 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
export async function updateHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = String(formData.get("name") ?? "")
.trim()
@@ -132,9 +132,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
+26 -8
View File
@@ -1,9 +1,12 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
import { logServerError } from "@/lib/server-log";
// website_permissions (model WebsitePermissions) is the CMS-owned permission ->
// minimum-rank mapping. Editable columns are exactly: permission (unique name),
@@ -37,10 +40,13 @@ export async function createPermission(formData: FormData): Promise<void> {
description: `Saved permission "${permission}" (min rank ${minRank})`,
targetType: "permission",
});
} catch {
} catch (error) {
logServerError("admin.permission_create_failed", error, { staffId: staff.id, permission });
redirect("/admin/permissions?error=save");
// ignore (e.g. constraint failure) — page re-renders current state
}
revalidatePath("/admin/permissions");
redirect("/admin/permissions?saved=1");
}
export async function updatePermission(formData: FormData): Promise<void> {
@@ -63,33 +69,45 @@ export async function updatePermission(formData: FormData): Promise<void> {
await logStaffActivity({
staffId: staff.id,
action: "permission_update",
description: `Updated permission #${raw} ("${permission}" min rank ${minRank})`,
description: `Updated permission #${id} ("${permission}" min rank ${minRank})`,
targetType: "permission",
});
} catch {
} catch (error) {
logServerError("admin.permission_update_failed", error, {
staffId: staff.id,
permissionId: String(id),
});
redirect("/admin/permissions?error=save");
// ignore (e.g. duplicate) — page re-renders current state
}
revalidatePath("/admin/permissions");
redirect("/admin/permissions?saved=1");
}
export async function deletePermission(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
const deleted = await prisma.websitePermissions.delete({
where: { id: BigInt(raw) },
where: { id },
select: { permission: true },
});
await logStaffActivity({
staffId: staff.id,
action: "permission_delete",
description: `Deleted permission #${raw} ("${deleted.permission}")`,
description: `Deleted permission #${id} ("${deleted.permission}")`,
targetType: "permission",
});
} catch {
} catch (error) {
logServerError("admin.permission_delete_failed", error, {
staffId: staff.id,
permissionId: String(id),
});
redirect("/admin/permissions?error=delete");
// ignore (e.g. already removed)
}
revalidatePath("/admin/permissions");
redirect("/admin/permissions?saved=1");
}
+7 -9
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data";
export async function createCategory(formData: FormData): Promise<void> {
await requireStaff();
@@ -28,9 +29,8 @@ export async function createCategory(formData: FormData): Promise<void> {
export async function deleteCategory(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
// Remove the category's values first to avoid orphaned rows.
@@ -44,9 +44,8 @@ export async function deleteCategory(formData: FormData): Promise<void> {
export async function createValue(formData: FormData): Promise<void> {
await requireStaff();
const categoryRaw = String(formData.get("categoryId") ?? "");
if (!/^\d+$/.test(categoryRaw)) return;
const categoryId = BigInt(categoryRaw);
const categoryId = formPositiveBigInt(formData, "categoryId");
if (!categoryId) return;
const name = String(formData.get("name") ?? "")
.trim()
@@ -90,9 +89,8 @@ export async function createValue(formData: FormData): Promise<void> {
export async function deleteValue(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await prisma.websiteRareValues.delete({ where: { id } });
+18 -19
View File
@@ -5,6 +5,8 @@ import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { formPositiveBigInt } from "@/lib/form-data";
import { logServerError } from "@/lib/server-log";
// Website store packages (website_shop_articles). This CMS-owned table backs
// the public store; rows here are the buyable packages, not orders. The closest
@@ -68,7 +70,8 @@ export async function createShopArticle(formData: FormData): Promise<void> {
targetType: "shop_article",
targetId: Number(created.id),
});
} catch {
} catch (error) {
logServerError("admin.shop_create_failed", error, { staffId: staff.id, name });
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
return;
}
@@ -79,14 +82,8 @@ export async function createShopArticle(formData: FormData): Promise<void> {
export async function updateShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = String(formData.get("name") ?? "")
.trim()
@@ -127,7 +124,11 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
targetType: "shop_article",
targetId: Number(id),
});
} catch {
} catch (error) {
logServerError("admin.shop_update_failed", error, {
staffId: staff.id,
articleId: String(id),
});
return;
}
@@ -138,14 +139,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
export async function deleteShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await prisma.websiteShopArticles.delete({ where: { id } });
@@ -156,7 +151,11 @@ export async function deleteShopArticle(formData: FormData): Promise<void> {
targetType: "shop_article",
targetId: Number(id),
});
} catch {
} catch (error) {
logServerError("admin.shop_delete_failed", error, {
staffId: staff.id,
articleId: String(id),
});
return;
}
+8 -11
View File
@@ -3,6 +3,8 @@
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { formPositiveBigInt } from "@/lib/form-data";
import { logServerError } from "@/lib/server-log";
export async function createVoucher(formData: FormData): Promise<void> {
await requireStaff();
@@ -37,7 +39,8 @@ export async function createVoucher(formData: FormData): Promise<void> {
updatedAt: now,
},
});
} catch {
} catch (error) {
logServerError("admin.voucher_create_failed", error);
// Unique constraint on `code` (or DB unavailable) — swallow and re-render.
return;
}
@@ -48,19 +51,13 @@ export async function createVoucher(formData: FormData): Promise<void> {
export async function deleteVoucher(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await prisma.websiteShopVouchers.delete({ where: { id } });
} catch {
} catch (error) {
logServerError("admin.voucher_delete_failed", error, { voucherId: String(id) });
return;
}
+6 -5
View File
@@ -2,18 +2,19 @@
//
// Reads the NextAuth session, then re-queries prisma.user by the session id to
// return a safe field set (never password / auth_ticket / 2FA secrets / pincode
// / mail). Returns { user: null } when unauthenticated or on DB failure.
// / mail). Returns { user: null } only when unauthenticated or missing.
import { apiJson } from "@/lib/api";
import { apiJson, apiUnavailable } from "@/lib/api";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sessionUserId } from "@/lib/auth/session-user";
export const dynamic = "force-dynamic";
export async function GET(_req: Request) {
const session = await auth();
const id = session?.user?.id ? Number(session.user.id) : null;
if (!id || Number.isNaN(id)) {
const id = sessionUserId(session?.user?.id);
if (!id) {
return apiJson({ user: null });
}
@@ -55,6 +56,6 @@ export async function GET(_req: Request) {
},
});
} catch {
return apiJson({ user: null });
return apiUnavailable("Account data is temporarily unavailable");
}
}
+10 -8
View File
@@ -8,16 +8,17 @@
// to the user via personal_access_tokens.tokenable_id (a BigInt). NOTE: the live
// table has no expires_at column, so it is never read or written here.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sessionUserId } from "@/lib/auth/session-user";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
export const dynamic = "force-dynamic";
async function currentUserId(): Promise<number | null> {
const session = await auth();
const id = session?.user?.id ? Number(session.user.id) : null;
return id && !Number.isNaN(id) ? id : null;
return sessionUserId(session?.user?.id);
}
export async function GET(_req: Request) {
@@ -26,14 +27,14 @@ export async function GET(_req: Request) {
try {
const tokens = await prisma.personalAccessTokens.findMany({
where: { tokenableId: BigInt(id) },
where: personalTokenScope(id),
select: { id: true, name: true, lastUsedAt: true },
orderBy: { id: "desc" },
});
// Never expose the token hash.
return apiJson({ data: tokens });
} catch {
return apiJson({ data: [] });
return apiUnavailable("Token data is temporarily unavailable");
}
}
@@ -42,20 +43,21 @@ export async function DELETE(req: Request) {
if (!id) return apiError("Unauthorized", 401);
const tokenId = new URL(req.url).searchParams.get("id");
if (!tokenId || !/^\d+$/.test(tokenId)) {
const parsedTokenId = positiveBigInt(tokenId);
if (!parsedTokenId) {
return apiError("A valid token id is required", 422);
}
try {
// Scope the delete to the owner so users cannot revoke others' tokens.
const result = await prisma.personalAccessTokens.deleteMany({
where: { id: BigInt(tokenId), tokenableId: BigInt(id) },
where: { id: parsedTokenId, ...personalTokenScope(id) },
});
if (result.count === 0) {
return apiError("Token not found", 404);
}
return apiJson({ ok: true });
} catch {
return apiError("Could not revoke token", 400);
return apiUnavailable("Could not revoke token");
}
}
+65 -22
View File
@@ -1,11 +1,18 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
import { prisma } from "@/lib/prisma";
import { captureOrder, creditsPerUnit, isPayPalConfigured } from "@/lib/services/paypal";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import {
authorizeTopupCapture,
claimTopupDelivery,
TopupCaptureError,
} from "@/lib/services/paypal-topup";
import { logServerError } from "@/lib/server-log";
export const dynamic = "force-dynamic";
@@ -29,8 +36,8 @@ export async function POST(req: Request): Promise<Response> {
if (!session?.user?.id) {
return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) {
const userId = sessionUserId(session.user.id);
if (!userId) {
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
}
@@ -53,22 +60,49 @@ export async function POST(req: Request): Promise<Response> {
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
}
// Idempotency: if this order was already recorded, don't capture/credit again.
// The create endpoint records ownership before returning the approval URL.
// Do not let a signed-in user submit somebody else's approved order id.
let authorized;
try {
const existing = await prisma.websitePaypalTransactions.findFirst({
where: { transactionId: orderId },
select: { id: true, status: true },
});
if (existing) {
authorized = await authorizeTopupCapture(userId, orderId, async (transactionId) => prisma.websitePaypalTransactions.findFirst({
where: { transactionId },
select: { userId: true, status: true, amount: true },
}));
} catch (error) {
if (error instanceof TopupCaptureError) {
const status = error.code === "ORDER_NOT_FOUND" ? 404 : 409;
return NextResponse.json({ error: "Order not found or already processed." }, { status });
}
return NextResponse.json({ error: "Could not verify the payment order." }, { status: 500 });
}
if (authorized.action === "DELIVER_CREDITS") {
const amount = authorized.amount ?? 0;
const credits = Math.floor(amount * creditsPerUnit());
try {
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}));
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
data: { status: "COMPLETED", updatedAt: new Date() },
});
return NextResponse.json({
ok: existing.status === "COMPLETED",
alreadyProcessed: true,
status: existing.status,
ok: true,
recovered: true,
status: "COMPLETED",
amount,
credits,
});
} catch (error) {
logServerError("paypal.credit_recovery_failed", error, { userId, orderId });
return NextResponse.json(
{ error: "Payment is recorded but credits could not be delivered. Contact staff." },
{ status: 500 },
);
}
} catch {
// If the lookup fails we fall through; the capture call itself is the source
// of truth and PayPal rejects a second capture of the same order.
}
let result;
@@ -85,10 +119,9 @@ export async function POST(req: Request): Promise<Response> {
if (result.status !== "COMPLETED") {
// Record the non-completed attempt so support can trace it.
try {
await prisma.websitePaypalTransactions.create({
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREATED" },
data: {
userId,
transactionId: result.id || orderId,
status: result.status,
description: `${env.HOTEL_NAME} top-up (not completed)`,
amount: result.amount,
@@ -111,11 +144,10 @@ export async function POST(req: Request): Promise<Response> {
// Record the transaction BEFORE crediting so a crash mid-grant can't be
// reprocessed into a double credit (the idempotency check above keys on this).
try {
await prisma.websitePaypalTransactions.create({
const claimed = await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREATED" },
data: {
userId,
transactionId: result.captureId ?? result.id,
status: "COMPLETED",
status: "CAPTURED_PENDING_CREDIT",
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
amount: result.amount,
currency: result.currency,
@@ -123,6 +155,7 @@ export async function POST(req: Request): Promise<Response> {
updatedAt: new Date(),
},
});
if (claimed.count !== 1) throw new Error("Top-up order was already claimed");
} catch (e) {
logger.error("PayPal capture record failed", { module: "paypal/capture", error: (e as Error).message });
return NextResponse.json(
@@ -131,9 +164,19 @@ export async function POST(req: Request): Promise<Response> {
);
}
// Credit the buyer's website credits wallet (RCON-first, DB fallback).
// Atomically claim delivery so concurrent retries cannot grant twice. If the
// external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible
// for staff reconciliation instead of automatically risking a second grant.
try {
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}));
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
data: { status: "COMPLETED", updatedAt: new Date() },
});
} catch (e) {
logger.error("PayPal capture credit failed", { module: "paypal/capture", error: (e as Error).message });
return NextResponse.json(
+12
View File
@@ -1,8 +1,11 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
import { prisma } from "@/lib/prisma";
import { createOrder, creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import { recordCreatedTopup } from "@/lib/services/paypal-topup";
export const dynamic = "force-dynamic";
@@ -21,6 +24,10 @@ export async function POST(req: Request): Promise<Response> {
if (!session?.user?.id) {
return NextResponse.json({ error: "You must be signed in to top up." }, { status: 401 });
}
const userId = sessionUserId(session.user.id);
if (!userId) {
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
}
// Fail fast (and clearly) when the sandbox/live keys aren't set.
if (!isPayPalConfigured()) {
@@ -63,6 +70,11 @@ export async function POST(req: Request): Promise<Response> {
);
}
await recordCreatedTopup(
{ userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits },
(data) => prisma.websitePaypalTransactions.create({ data }),
);
return NextResponse.json({
id: order.id,
approveUrl: order.approveUrl,
+5 -7
View File
@@ -1,7 +1,7 @@
// Public REST: website store packages (website_shop_articles).
// AtomCMS JSON API parity — read-only list of buyable packages, paginated and
// ordered by `position` (then name), matching the admin /admin/shop query.
import { apiJson, pagination } from "@/lib/api";
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -13,12 +13,10 @@ export async function GET(req: Request) {
// Optional ?category=<id> filter (website_shop_category_id is a BigInt).
const categoryRaw = sp.get("category");
let where: { categoryId?: bigint } = {};
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
try {
where = { categoryId: BigInt(categoryRaw) };
} catch {
where = {};
}
if (categoryRaw !== null) {
const categoryId = positiveBigInt(categoryRaw);
if (!categoryId) return apiError("A valid category id is required", 422);
where = { categoryId };
}
try {
+22 -24
View File
@@ -4,9 +4,10 @@
// into website_help_center_ticket_replies. The target ticket must exist and
// belong to the authed user. Fail-soft: never a 500.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
export const dynamic = "force-dynamic";
@@ -16,8 +17,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
if (!uid) return apiError("Unauthorized", 401);
const { id } = await params;
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
const ticketId = BigInt(id);
const ticketId = positiveBigInt(id);
if (!ticketId) return apiError("Invalid ticket id", 422);
const body = (await req.json().catch(() => ({}))) as { content?: unknown };
const content = String(body.content ?? "")
@@ -27,28 +28,25 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
try {
// Ownership check — only the ticket owner may reply.
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true },
});
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
const now = new Date();
const reply = await prisma.websiteHelpCenterTicketReplies.create({
data: {
ticketId,
userId: uid,
content,
createdAt: now,
updatedAt: now,
const reply = await prisma.$transaction((tx) => createOwnedTicketReply(
{
findTicket: (ticketId) => tx.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true },
}),
createReply: (data) => tx.websiteHelpCenterTicketReplies.create({
data,
select: { id: true, userId: true, content: true, createdAt: true },
}),
touchTicket: (ticketId, updatedAt) => tx.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { updatedAt },
select: { id: true },
}),
},
select: { id: true, userId: true, content: true, createdAt: true },
});
// Touch the parent ticket so its updatedAt reflects the latest activity.
prisma.websiteHelpCenterTickets
.update({ where: { id: ticketId }, data: { updatedAt: now }, select: { id: true } })
.catch(() => {});
{ ticketId, userId: uid, content },
));
if (!reply) return apiError("Ticket not found", 404);
return apiJson(
{
+3 -3
View File
@@ -4,7 +4,7 @@
// together with its replies; reply author usernames are resolved in a single
// users lookup. Fail-soft: never a 500.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
@@ -16,8 +16,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
if (!uid) return apiError("Unauthorized", 401);
const { id } = await params;
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
const ticketId = BigInt(id);
const ticketId = positiveBigInt(id);
if (!ticketId) return apiError("Invalid ticket id", 422);
try {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
+3 -3
View File
@@ -4,7 +4,7 @@
// Backed by website_help_center_tickets (WebsiteHelpCenterTickets). Fail-soft:
// DB errors return an apiError envelope, never a 500.
import { apiError, apiJson } from "@/lib/api";
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
@@ -59,8 +59,8 @@ export async function POST(req: Request) {
// value, otherwise leave it null.
let categoryId: bigint | null = null;
if (body.categoryId !== undefined && body.categoryId !== null && body.categoryId !== "") {
const raw = String(body.categoryId);
if (/^\d+$/.test(raw)) categoryId = BigInt(raw);
categoryId = positiveBigInt(String(body.categoryId));
if (!categoryId) return apiError("A valid category id is required", 422);
}
try {
+3 -2
View File
@@ -8,13 +8,14 @@
import { apiError, apiJson } from "@/lib/api";
import { issueToken } from "@/lib/api-auth";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const session = await auth();
const id = session?.user?.id ? Number(session.user.id) : null;
if (!id || Number.isNaN(id)) {
const id = sessionUserId(session?.user?.id);
if (!id) {
return apiError("Unauthorized", 401);
}
+5 -7
View File
@@ -1,7 +1,7 @@
// Public REST: rare furni trade values (website_rare_values).
// AtomCMS JSON API parity — read-only catalog of rares with their credit /
// currency values. Supports ?category=<id> filter; paginated, ordered by name.
import { apiJson, pagination } from "@/lib/api";
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -13,12 +13,10 @@ export async function GET(req: Request) {
// Optional ?category=<id> filter (category_id is a BigInt).
const categoryRaw = sp.get("category");
let where: { categoryId?: bigint } = {};
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
try {
where = { categoryId: BigInt(categoryRaw) };
} catch {
where = {};
}
if (categoryRaw !== null) {
const categoryId = positiveBigInt(categoryRaw);
if (!categoryId) return apiError("A valid category id is required", 422);
where = { categoryId };
}
try {
+2 -2
View File
@@ -20,7 +20,7 @@ function ToolbarBtn({
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
color: "var(--button-text-color, #1a1a2e)",
color: "var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
} as const;
if (href) {
return (
@@ -139,7 +139,7 @@ export function ClientView({
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
color: "var(--button-text-color, #1a1a2e)",
color: "var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
}}
title="Online users"
>
+43 -24
View File
@@ -28,6 +28,13 @@
--color-navbar-text: #1e293b;
--color-text: #0f172a;
--color-text-muted: #6b7280;
--color-text-readable: #0f172a;
--color-text-muted-readable: #6b7280;
--color-primary-readable: #000000;
--color-accent-readable: #006b49;
--color-primary-foreground-readable: #1a1a2e;
--color-accent-foreground-readable: #000000;
--color-navbar-text-readable: #1e293b;
--color-accent: #10b981;
--color-success: #16a34a;
--color-warning: #eab308;
@@ -38,11 +45,14 @@
--gradient-to: #10b981;
--button-color: #eeb425;
--button-text-color: #1a1a2e;
--button-text-color-readable: #1a1a2e;
--button-secondary-color: #22c55e;
--button-secondary-text-color: #ffffff;
--button-secondary-text-color-readable: #000000;
--button-secondary-hover-color: var(--color-success);
--button-danger-color: #ef4444;
--button-danger-text-color: #ffffff;
--button-danger-text-color-readable: #000000;
--button-danger-hover-color: #dc2626;
--button-outline-color: #eeb425;
--button-outline-text-color: #1a1a2e;
@@ -61,6 +71,8 @@
--card-border-radius: 14px;
--link-color: #eeb425;
--link-hover-color: #d4a01f;
--link-color-readable: #000000;
--link-hover-color-readable: #000000;
--input-border-color: #d1d5db;
--input-focus-color: #eeb425;
--navbar-height: 56px;
@@ -77,12 +89,19 @@ html.dark {
--color-navbar-text: #e5e7eb;
--color-text: #e5e7eb;
--color-text-muted: #94a3b8;
--color-text-readable: var(--color-text);
--color-text-muted-readable: var(--color-text-muted);
--color-primary-readable: var(--color-text);
--color-accent-readable: var(--color-text);
--color-navbar-text-readable: var(--color-navbar-text);
--link-color-readable: var(--color-text);
--link-hover-color-readable: var(--color-text);
}
html.dark input,
html.dark select,
html.dark textarea {
background: #11151c;
color: var(--color-text);
color: var(--color-text-readable, var(--color-text));
}
html.dark .admin-page table,
html.dark .admin-card {
@@ -98,7 +117,7 @@ html {
}
body {
font-family: var(--font-family);
color: var(--color-text, #0f172a);
color: var(--color-text-readable, var(--color-text, #0f172a));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -120,7 +139,7 @@ body {
@layer components {
.nav-item {
@apply flex h-auto md:h-[54px] items-center text-[13px] font-bold transition-all duration-150 ease-in-out px-3 md:px-4 relative;
color: var(--color-navbar-text, #64748b);
color: var(--color-navbar-text-readable, var(--color-navbar-text, #64748b));
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
@@ -141,15 +160,15 @@ body {
transform: translateX(-50%) scaleX(1);
}
.nav-item:hover {
color: var(--color-primary);
color: var(--color-primary-readable, var(--color-primary));
}
.dropdown-item {
@apply block py-2.5 px-4 text-sm font-semibold transition-all duration-100 rounded-lg mx-1;
color: var(--color-text);
color: var(--color-text-readable, var(--color-text));
}
.dropdown-item:hover {
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
color: var(--color-primary-readable, var(--color-primary));
}
.dropdown-menu {
background-color: var(--color-dropdown);
@@ -186,10 +205,10 @@ body {
}
.text-muted {
color: var(--color-text-muted);
color: var(--color-text-muted-readable, var(--color-text-muted));
}
.text-nav {
color: var(--color-navbar-text);
color: var(--color-navbar-text-readable, var(--color-navbar-text));
}
.text-shadow {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
@@ -221,14 +240,14 @@ h3 {
font-weight: 700;
}
a {
color: var(--link-color);
color: var(--link-color-readable, var(--link-color));
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.muted {
color: var(--color-text-muted);
color: var(--color-text-muted-readable, var(--color-text-muted));
font-size: 0.875rem;
}
.hero {
@@ -245,7 +264,7 @@ a:hover {
}
.card {
background: var(--color-navbar);
color: var(--color-navbar-text);
color: var(--color-navbar-text-readable, var(--color-navbar-text));
border: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
border-radius: var(--card-border-radius);
box-shadow: var(--shadow-card);
@@ -282,7 +301,7 @@ a:hover {
cursor: pointer;
transition: all 0.18s ease;
background: #eef2f7;
color: var(--color-text);
color: var(--color-text-readable, var(--color-text));
text-decoration: none;
}
.btn:hover {
@@ -292,15 +311,15 @@ a:hover {
}
.btn-primary {
background: var(--button-color);
color: var(--button-text-color);
color: var(--button-text-color-readable, var(--button-text-color));
}
.btn-secondary {
background: var(--button-secondary-color);
color: var(--button-secondary-text-color);
color: var(--button-secondary-text-color-readable, var(--button-secondary-text-color));
}
.btn-danger {
background: var(--button-danger-color);
color: var(--button-danger-text-color);
color: var(--button-danger-text-color-readable, var(--button-danger-text-color));
}
.btn-outline {
background: transparent;
@@ -316,7 +335,7 @@ textarea {
border: 2px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
color: var(--color-text-readable, var(--color-text));
outline: none;
transition:
border-color 0.2s ease,
@@ -334,7 +353,7 @@ table {
}
th {
text-align: left;
color: var(--color-text-muted);
color: var(--color-text-muted-readable, var(--color-text-muted));
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
@@ -406,7 +425,7 @@ tbody tr:hover {
/* Cards used inside admin pages */
.admin-card {
background: var(--color-navbar);
color: var(--color-navbar-text);
color: var(--color-navbar-text-readable, var(--color-navbar-text));
border: 1px solid var(--border-subtle);
border-radius: 16px;
box-shadow: var(--shadow-card);
@@ -519,7 +538,7 @@ tbody tr:hover {
flex-direction: column;
width: 100%;
background: var(--color-navbar);
color: var(--color-navbar-text);
color: var(--color-navbar-text-readable, var(--color-navbar-text));
border: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
@@ -565,12 +584,12 @@ tbody tr:hover {
font-weight: 700;
font-size: 1rem;
line-height: 1.3;
color: var(--color-navbar-text);
color: var(--color-navbar-text-readable, var(--color-navbar-text));
}
.content-card-subtitle {
margin: 0.15rem 0 0;
font-size: 0.78rem;
color: var(--color-text-muted);
color: var(--color-text-muted-readable, var(--color-text-muted));
}
.content-card-action {
flex: none;
@@ -624,12 +643,12 @@ tbody tr:hover {
font-size: 1.6rem;
font-weight: 800;
line-height: 1.1;
color: var(--color-primary);
color: var(--color-primary-readable, var(--color-primary));
}
.stat-block-label {
margin-top: 0.2rem;
font-size: 0.78rem;
color: var(--color-text-muted);
color: var(--color-text-muted-readable, var(--color-text-muted));
}
/* Empty states */
@@ -652,7 +671,7 @@ tbody tr:hover {
padding: 0.1rem 0.55rem 0.1rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 14%, transparent);
color: var(--color-text-muted);
color: var(--color-text-muted-readable, var(--color-text-muted));
}
.online-badge.online {
background: color-mix(in srgb, var(--color-success) 16%, transparent);
+1 -1
View File
@@ -28,7 +28,7 @@ function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
+2 -2
View File
@@ -67,7 +67,7 @@ export default async function NewsPage() {
style={{
margin: 0,
fontSize: "1.05rem",
color: "var(--color-navbar-text)",
color: "var(--color-navbar-text-readable, var(--color-navbar-text))",
lineHeight: 1.4,
}}
>
@@ -79,7 +79,7 @@ export default async function NewsPage() {
<p
style={{
margin: 0,
color: "var(--color-navbar-text)",
color: "var(--color-navbar-text-readable, var(--color-navbar-text))",
fontSize: "0.88rem",
lineHeight: 1.55,
}}
+2 -2
View File
@@ -37,7 +37,7 @@ export default async function RadioLayout({ children }: { children: ReactNode })
padding: "0.55rem 0.75rem",
fontWeight: 700,
fontSize: "1rem",
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
textDecoration: "none",
borderBottom: "1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
marginBottom: "0.25rem",
@@ -59,7 +59,7 @@ export default async function RadioLayout({ children }: { children: ReactNode })
gap: "0.5rem",
padding: "0.5rem 0.75rem",
borderRadius: "8px",
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
textDecoration: "none",
fontSize: "0.9rem",
transition: "background 0.15s",
+3 -3
View File
@@ -87,21 +87,21 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna
icon: "💰",
label: t("statCredits"),
value: user.credits,
color: "var(--color-primary)",
color: "var(--color-primary-readable, var(--color-primary))",
},
{
key: "duckets",
icon: "🪙",
label: t("statDuckets"),
value: ducketsAmount,
color: "var(--color-accent)",
color: "var(--color-accent-readable, var(--color-accent))",
},
{
key: "diamonds",
icon: "💎",
label: t("statDiamonds"),
value: diamondsAmount,
color: "#38bdf8",
color: "var(--color-text-readable, var(--color-text))",
},
];
+2 -2
View File
@@ -45,7 +45,7 @@ export function DiscordVerifyForm() {
</div>
)}
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
<p className="text-sm" style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}>
Open Discord, ga naar <strong>Instellingen → Geavanceerd → Ontwikkelaarsmodus</strong> (aan). Klik met
rechts op je eigen naam en kies <strong>ID kopiëren</strong>. Plak dat hier:
</p>
@@ -59,7 +59,7 @@ export function DiscordVerifyForm() {
className="flex-1 focus:ring-0 border-4 rounded text-sm px-3 py-2"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
+11 -11
View File
@@ -76,7 +76,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
<label
htmlFor="username"
className="block font-semibold text-sm"
style={{ color: "var(--color-text)" }}
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("username")}
</label>
@@ -89,7 +89,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
@@ -101,7 +101,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
<label
htmlFor="mail"
className="block font-semibold text-sm"
style={{ color: "var(--color-text)" }}
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("email")}
</label>
@@ -114,13 +114,13 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
</fieldset>
</div>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}>
Geen e-mail? Je kunt later via Discord verifiëren.
</p>
</div>
@@ -132,7 +132,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
<label
htmlFor="password"
className="block font-semibold text-sm"
style={{ color: "var(--color-text)" }}
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("password")}
</label>
@@ -145,7 +145,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
@@ -157,7 +157,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
<label
htmlFor="password_confirmation"
className="block font-semibold text-sm"
style={{ color: "var(--color-text)" }}
style={{ color: "var(--color-text-readable, var(--color-text))" }}
>
{t("confirmPassword")}
</label>
@@ -170,7 +170,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
@@ -209,7 +209,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
<input type="hidden" name="terms" value={termsAccepted ? "1" : ""} />
<span
className="font-semibold cursor-pointer"
style={{ color: "var(--color-text)" }}
style={{ color: "var(--color-text-readable, var(--color-text))" }}
onClick={() => setTermsAccepted(!termsAccepted)}
>
{t("termsAccept", { hotel: hotelName })}
@@ -242,7 +242,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
<Link
href="/login"
className="text-sm font-semibold hover:underline"
style={{ color: "var(--color-primary)" }}
style={{ color: "var(--color-primary-readable, var(--color-primary))" }}
>
{t("alreadyHaveAccount")}
</Link>
+1 -1
View File
@@ -156,7 +156,7 @@ export default async function GuestView() {
</div>
<div className="p-4">
{articles.length === 0 ? (
<p className="py-8 text-center" style={{ color: "var(--color-text-muted)" }}>
<p className="py-8 text-center" style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}>
{t("noArticles")}
</p>
) : (
+3 -3
View File
@@ -89,7 +89,7 @@ export default async function UserView({ userId, username, look }: UserViewProps
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
color: "var(--button-text-color)",
color: "var(--button-text-color-readable, var(--button-text-color))",
border: "1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
@@ -127,7 +127,7 @@ export default async function UserView({ userId, username, look }: UserViewProps
{onlineFriends.length === 0 ? (
<p
className="mb-3 block w-full text-center text-xs font-medium md:mb-0 md:text-left"
style={{ color: "var(--color-text-muted)" }}
style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}
>
{t("noFriendsOnline")}
</p>
@@ -185,7 +185,7 @@ export default async function UserView({ userId, username, look }: UserViewProps
<div className="p-3">
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
{latestArticle.shortStory && (
<p className="mt-1 text-xs" style={{ color: "var(--color-text-muted)" }}>
<p className="mt-1 text-xs" style={{ color: "var(--color-text-muted-readable, var(--color-text-muted))" }}>
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
+1 -1
View File
@@ -70,7 +70,7 @@ export function LanguageSwitcher() {
l.code === locale ? "opacity-100" : "opacity-70 hover:opacity-100"
}`}
style={{
color: "var(--color-text)",
color: "var(--color-text-readable, var(--color-text))",
background: "none",
border: "none",
cursor: "pointer",
+1 -1
View File
@@ -18,7 +18,7 @@ export function MobileNav({ children, menuLabel = "Open menu", closeLabel = "Clo
type="button"
onClick={() => setOpen(!open)}
className="flex md:hidden items-center justify-center w-10 h-10 rounded-lg transition-colors duration-150"
style={{ color: "var(--color-navbar-text)" }}
style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text))" }}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? closeLabel : menuLabel}
+1 -1
View File
@@ -249,7 +249,7 @@ export default function RadioPlayer() {
border: "none",
cursor: "pointer",
background: primary,
color: "var(--button-text-color)",
color: "var(--button-text-color-readable, var(--button-text-color))",
fontSize: "1rem",
lineHeight: 1,
}}
+4 -1
View File
@@ -9,7 +9,10 @@ export async function SiteFooter() {
return (
<footer
className="mt-auto flex h-14 w-full flex-col items-center justify-center text-sm md:flex-row md:px-8"
style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text-muted)" }}
style={{
backgroundColor: "var(--color-surface)",
color: "var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
<div className="md:font-semibold text-[12px] md:text-[14px]">
{t("copyright", { year, hotel: hotelName ?? "Atom" })}
+46 -2
View File
@@ -1,5 +1,6 @@
import { siteSettings } from "@/lib/services/site-settings";
import { FONTS } from "@/lib/theme-presets";
import { readableColor } from "@/lib/theme-contrast";
// Injects the DB-driven CSS custom properties into :root, exactly like
// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers
@@ -78,12 +79,43 @@ export async function ThemeVars() {
const safe = (v: string | null, d: string) => (v && /^[#a-zA-Z0-9(),.\s%-]+$/.test(v) ? v : d);
const px = (v: string | null, d: string) => (/^\d{1,3}$/.test(v ?? "") ? (v as string) : d);
const safeBackground = safe(background, "#f8fafc");
const safeSurface = safe(surface, "#ffffff");
const publicBackgrounds = [safeBackground, safeSurface];
const readableText = readableColor(safe(text, "#0f172a"), publicBackgrounds);
const readableMuted = readableColor(safe(textMuted, "#64748b"), publicBackgrounds);
const readablePrimary = readableColor(safe(primary, "#f59e0b"), publicBackgrounds);
const readableAccent = readableColor(safe(accent, "#10b981"), publicBackgrounds);
const readableLink = readableColor(safe(linkColor, "#eeb425"), publicBackgrounds);
const readableLinkHover = readableColor(safe(linkHover, "#cf9d15"), publicBackgrounds);
const readableButtonText = readableColor(
safe(buttonText, "#1e293b"),
[safe(buttonColor, "#f59e0b")],
);
const readableSecondaryText = readableColor(
safe(buttonSecondaryText, "#ffffff"),
[safe(buttonSecondary, "#22c55e")],
);
const readableDangerText = readableColor(
safe(buttonDangerText, "#ffffff"),
[safe(buttonDanger, "#ef4444")],
);
const readableNavbarText = readableColor(
safe(navbarText, "#1e293b"),
[safe(navbar, "#ffffff")],
);
const readablePrimaryForeground = readableColor(
safe(buttonText, "#1e293b"),
[safe(primary, "#f59e0b")],
);
const readableAccentForeground = readableColor("#ffffff", [safe(accent, "#10b981")]);
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
const css = `:root{
--color-primary:${safe(primary, "#f59e0b")};
--color-background:${safe(background, "#f8fafc")};
--color-surface:${safe(surface, "#ffffff")};
--color-background:${safeBackground};
--color-surface:${safeSurface};
--color-dropdown:${safe(dropdown, "#ffffff")};
--color-navbar:${safe(navbar, "#ffffff")};
--color-navbar-text:${safe(navbarText, "#1e293b")};
@@ -107,6 +139,18 @@ export async function ThemeVars() {
--border-color:${safe(borderColor, "#eeb425")};
--gradient-from:${safe(gradientFrom, "#f59e0b")};
--gradient-to:${safe(gradientTo, "#10b981")};
--color-text-readable:${readableText};
--color-text-muted-readable:${readableMuted};
--color-primary-readable:${readablePrimary};
--color-accent-readable:${readableAccent};
--color-primary-foreground-readable:${readablePrimaryForeground};
--color-accent-foreground-readable:${readableAccentForeground};
--color-navbar-text-readable:${readableNavbarText};
--button-text-color-readable:${readableButtonText};
--button-secondary-text-color-readable:${readableSecondaryText};
--button-danger-text-color-readable:${readableDangerText};
--link-color-readable:${readableLink};
--link-hover-color-readable:${readableLinkHover};
--border-radius:${px(borderRadius, "12")}px;
--font-family:${font.stack};
--size-heading-h1:${px(h1, "30")}px;
+4 -4
View File
@@ -9,8 +9,8 @@ function Currency({ icon, amount, label }: { icon: string; amount: number; label
return (
<div className="flex gap-x-3 sm:gap-x-2 items-center">
<div className={`h-[25px] w-[25px] rounded-full outline-offset-[3px] ${icon}`} />
<div style={{ color: "var(--color-navbar-text, var(--color-text-muted))" }}>
<span className="font-semibold" style={{ color: "var(--color-navbar-text, var(--color-text))" }}>
<div style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text-muted)))" }}>
<span className="font-semibold" style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))" }}>
{amount.toLocaleString()}
</span>{" "}
<span>{label}</span>
@@ -62,7 +62,7 @@ export async function TopHeader() {
<details className="relative">
<summary
className="list-none cursor-pointer font-semibold [&::-webkit-details-marker]:hidden"
style={{ color: "var(--color-navbar-text, var(--color-text))" }}
style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))" }}
>
Administration
</summary>
@@ -101,7 +101,7 @@ export async function TopHeader() {
/>
<span
className="-ml-2 font-semibold"
style={{ color: "var(--color-navbar-text, var(--color-text))" }}
style={{ color: "var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))" }}
>
{session.user.name}
</span>
+10 -6
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation";
import { isStaff } from "@/lib/admin/is-staff";
import { resolveStaffUser } from "@/lib/admin/staff-user";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { clientIp, rateLimit } from "@/lib/rate-limit";
@@ -25,12 +27,14 @@ export async function requireStaff(): Promise<StaffUser> {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const minRank = await getMinStaffRank();
if (!isStaff(session.user.rank, minRank)) redirect("/");
return {
id: Number(session.user.id),
rank: session.user.rank,
username: session.user.name ?? "",
};
const staff = await resolveStaffUser(session.user.id, minRank, (id) =>
prisma.user.findUnique({
where: { id },
select: { id: true, rank: true, username: true },
}),
);
if (!staff) redirect("/");
return staff;
}
/**
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { adminMutationNotice } from "@/lib/admin/notice";
describe("adminMutationNotice", () => {
it("maps a successful redirect to a safe notice", () => {
expect(adminMutationNotice({ saved: "1" })).toEqual({
tone: "ok",
label: "Saved",
message: "Changes were saved successfully.",
});
});
it("maps an error code without reflecting arbitrary query text", () => {
expect(adminMutationNotice({ error: "<script>" })).toEqual({
tone: "danger",
label: "Error",
message: "The operation could not be completed.",
});
});
it("returns null when there is no mutation result", () => {
expect(adminMutationNotice({})).toBeNull();
});
});
+26
View File
@@ -0,0 +1,26 @@
export interface AdminMutationNotice {
tone: "ok" | "danger";
label: "Saved" | "Error";
message: string;
}
export function adminMutationNotice(params: {
saved?: string;
error?: string;
}): AdminMutationNotice | null {
if (params.error) {
return {
tone: "danger",
label: "Error",
message: "The operation could not be completed.",
};
}
if (params.saved === "1") {
return {
tone: "ok",
label: "Saved",
message: "Changes were saved successfully.",
};
}
return null;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { resolveStaffUser } from "@/lib/admin/staff-user";
describe("resolveStaffUser", () => {
it("rejects a session whose database rank has been revoked", async () => {
const findUser = async () => ({ id: 7, rank: 1, username: "Alice" });
await expect(resolveStaffUser("7", 7, findUser)).resolves.toBeNull();
});
it("returns the current database identity instead of stale JWT values", async () => {
const findUser = async () => ({ id: 7, rank: 8, username: "CurrentName" });
await expect(resolveStaffUser("7", 7, findUser)).resolves.toEqual({
id: 7,
rank: 8,
username: "CurrentName",
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import { isStaff } from "@/lib/admin/is-staff";
export interface StaffUserRecord {
id: number;
rank: number;
username: string;
}
export type FindStaffUser = (id: number) => Promise<StaffUserRecord | null>;
export async function resolveStaffUser(
sessionUserId: string,
minStaffRank: number,
findUser: FindStaffUser,
): Promise<StaffUserRecord | null> {
const id = Number(sessionUserId);
if (!Number.isSafeInteger(id) || id <= 0) return null;
const user = await findUser(id);
return user && isStaff(user.rank, minStaffRank) ? user : null;
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
describe("personalTokenScope", () => {
it("scopes token operations to the user model and owner id", () => {
expect(personalTokenScope(42)).toEqual({
tokenableId: 42n,
tokenableType: "App\\Models\\User",
});
});
});
+4 -5
View File
@@ -1,5 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { personalTokenScope, USER_TOKENABLE_TYPE } from "@/lib/auth/personal-token-scope";
import { databaseUserId } from "@/lib/auth/session-user";
/**
* Bearer-token auth for the public REST API, backed by personal_access_tokens
@@ -7,8 +9,6 @@ import { prisma } from "@/lib/prisma";
* stored as the sha256 of the plaintext; the client sends the plaintext (or the
* Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`.
*/
const TOKENABLE_TYPE = "App\\Models\\User";
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
@@ -36,7 +36,7 @@ export async function bearerUserId(req: Request): Promise<number | null> {
prisma.personalAccessTokens
.update({ where: { id: row.id }, data: { lastUsedAt: new Date() }, select: { id: true } })
.catch(() => {});
return Number(row.tokenableId);
return databaseUserId(row.tokenableId);
} catch {
return null;
}
@@ -48,8 +48,7 @@ export async function issueToken(userId: number, name = "api"): Promise<string |
try {
await prisma.personalAccessTokens.create({
data: {
tokenableId: BigInt(userId),
tokenableType: TOKENABLE_TYPE,
...personalTokenScope(userId),
name: name.slice(0, 100),
token: hashToken(plaintext),
abilities: '["*"]',
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { apiUnavailable, pagination, positiveBigInt } from "@/lib/api";
describe("apiUnavailable", () => {
it("returns a no-store 503 error without exposing internal details", async () => {
const response = apiUnavailable("Account data is temporarily unavailable");
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
await expect(response.json()).resolves.toEqual({
error: "Account data is temporarily unavailable",
});
});
});
describe("positiveBigInt", () => {
it.each(["1", "9007199254740993"])("parses positive integer id %s", (raw) => {
expect(positiveBigInt(raw)).toBe(BigInt(raw));
});
it.each([null, "", "0", "-1", "1.5", "abc"])("rejects invalid id %s", (raw) => {
expect(positiveBigInt(raw)).toBeNull();
});
});
describe("pagination", () => {
it("rejects fractional and malformed values before they reach Prisma", () => {
const params = new URLSearchParams({ page: "2.5", perPage: "10.1" });
expect(pagination(params, 20, 100)).toEqual({
page: 1,
perPage: 20,
skip: 0,
take: 20,
});
});
it("clamps valid page sizes to the configured maximum", () => {
const params = new URLSearchParams({ page: "3", perPage: "999" });
expect(pagination(params, 20, 100)).toEqual({
page: 3,
perPage: 100,
skip: 200,
take: 100,
});
});
});
+23 -5
View File
@@ -25,12 +25,30 @@ export function apiError(message: string, status = 400): NextResponse {
return apiJson({ error: message }, { status });
}
/** Safe response for temporary infrastructure failures. */
export function apiUnavailable(message = "Service temporarily unavailable"): NextResponse {
return apiError(message, 503);
}
/** Parse an unsigned, non-zero database identifier without Number precision loss. */
export function positiveBigInt(raw: string | null): bigint | null {
if (!raw || !/^\d+$/.test(raw)) return null;
const value = BigInt(raw);
return value > 0n ? value : null;
}
/** Clamp a ?page / ?perPage pair from search params. */
export function pagination(searchParams: URLSearchParams, defaultPer = 20, maxPer = 100) {
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const perPage = Math.min(
maxPer,
Math.max(1, Number(searchParams.get("perPage") ?? defaultPer) || defaultPer),
);
const requestedPage = positiveInteger(searchParams.get("page"), 1);
const requestedPerPage = positiveInteger(searchParams.get("perPage"), defaultPer);
const perPage = Math.min(maxPer, requestedPerPage);
const maxSafePage = Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1;
const page = Math.min(requestedPage, maxSafePage);
return { page, perPage, skip: (page - 1) * perPage, take: perPage };
}
function positiveInteger(raw: string | null, fallback: number): number {
if (!raw || !/^\d+$/.test(raw)) return fallback;
const value = Number(raw);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
}
+8
View File
@@ -0,0 +1,8 @@
export const USER_TOKENABLE_TYPE = "App\\Models\\User";
export function personalTokenScope(userId: number) {
return {
tokenableId: BigInt(userId),
tokenableType: USER_TOKENABLE_TYPE,
} as const;
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { databaseUserId, sessionUserId } from "@/lib/auth/session-user";
describe("sessionUserId", () => {
it("returns a positive safe integer from a valid session id", () => {
expect(sessionUserId("42")).toBe(42);
});
it.each([undefined, null, "", "0", "-1", "1.5", "abc", Number.MAX_SAFE_INTEGER + 1])(
"rejects invalid session id %s",
(value) => {
expect(sessionUserId(value)).toBeNull();
},
);
});
describe("databaseUserId", () => {
it("converts a safe positive database id", () => {
expect(databaseUserId(42n)).toBe(42);
});
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])(
"rejects unsafe database id %s",
(value) => {
expect(databaseUserId(value)).toBeNull();
},
);
});
+9
View File
@@ -0,0 +1,9 @@
export function sessionUserId(value: unknown): number | null {
const id = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export function databaseUserId(value: bigint): number | null {
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { formPositiveBigInt } from "@/lib/form-data";
describe("formPositiveBigInt", () => {
it("parses a positive identifier from FormData", () => {
const formData = new FormData();
formData.set("id", "42");
expect(formPositiveBigInt(formData, "id")).toBe(42n);
});
it.each(["", "0", "-1", "1.5", "invalid"])("rejects invalid identifier %s", (value) => {
const formData = new FormData();
formData.set("id", value);
expect(formPositiveBigInt(formData, "id")).toBeNull();
});
});
+6
View File
@@ -0,0 +1,6 @@
import { positiveBigInt } from "@/lib/api";
export function formPositiveBigInt(formData: FormData, field: string): bigint | null {
const value = formData.get(field);
return typeof value === "string" ? positiveBigInt(value.trim()) : null;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { serverErrorRecord } from "@/lib/server-log";
describe("serverErrorRecord", () => {
it("keeps operational context while redacting sensitive fields", () => {
expect(serverErrorRecord("paypal.capture_failed", new Error("gateway timeout"), {
orderId: "ORDER-123",
userId: 42,
authorization: "Bearer secret",
apiToken: "secret-token",
})).toEqual({
level: "error",
event: "paypal.capture_failed",
message: "gateway timeout",
context: {
orderId: "ORDER-123",
userId: 42,
authorization: "[REDACTED]",
apiToken: "[REDACTED]",
},
});
});
it("normalizes non-Error failures without serializing arbitrary objects", () => {
expect(serverErrorRecord("admin.update_failed", { password: "secret" })).toMatchObject({
message: "Unknown server error",
});
});
});
+39
View File
@@ -0,0 +1,39 @@
type LogScalar = string | number | boolean | null;
type LogContext = Record<string, unknown>;
const SENSITIVE_KEY = /(authorization|cookie|password|secret|token|api[-_]?key)/i;
export interface ServerErrorRecord {
level: "error";
event: string;
message: string;
context: Record<string, LogScalar>;
}
export function serverErrorRecord(
event: string,
error: unknown,
context: LogContext = {},
): ServerErrorRecord {
return {
level: "error",
event,
message: error instanceof Error ? error.message.slice(0, 500) : "Unknown server error",
context: Object.fromEntries(Object.entries(context).map(([key, value]) => [
key,
SENSITIVE_KEY.test(key) ? "[REDACTED]" : logScalar(value),
])),
};
}
export function logServerError(event: string, error: unknown, context: LogContext = {}): void {
console.error(JSON.stringify(serverErrorRecord(event, error, context)));
}
function logScalar(value: unknown): LogScalar {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? value
: value === null
? null
: "[NON_SCALAR]";
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
authorizeTopupCapture,
claimTopupDelivery,
recordCreatedTopup,
} from "@/lib/services/paypal-topup";
describe("authorizeTopupCapture", () => {
it("rejects an order created by a different user", async () => {
const findOrder = async () => ({
userId: 41,
status: "CREATED",
});
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
code: "ORDER_NOT_FOUND",
});
});
it("rejects an order that has already left the created state", async () => {
const findOrder = async () => ({
userId: 99,
status: "COMPLETED",
});
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
code: "ORDER_ALREADY_PROCESSED",
});
});
it("resumes credit delivery for a captured order without capturing it again", async () => {
const findOrder = async () => ({
userId: 99,
status: "CAPTURED_PENDING_CREDIT",
amount: 12.5,
});
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).resolves.toMatchObject({
action: "DELIVER_CREDITS",
amount: 12.5,
});
});
});
describe("recordCreatedTopup", () => {
it("stores the PayPal order id with its owner before approval", async () => {
let saved: Parameters<typeof record>[0] | undefined;
const record = async (data: {
userId: number;
transactionId: string;
status: string;
description: string;
amount: number;
currency: string;
createdAt: Date;
updatedAt: Date;
}) => {
saved = data;
};
await recordCreatedTopup(
{ userId: 99, orderId: "ORDER-123", amount: 12.5, currency: "EUR", credits: 1250 },
record,
);
expect(saved).toMatchObject({
userId: 99,
transactionId: "ORDER-123",
status: "CREATED",
amount: 12.5,
currency: "EUR",
});
});
});
describe("claimTopupDelivery", () => {
it("rejects delivery when another request already claimed the order", async () => {
const claim = async () => ({ count: 0 });
await expect(claimTopupDelivery(claim)).rejects.toMatchObject({
code: "DELIVERY_ALREADY_CLAIMED",
});
});
it("allows delivery after atomically claiming the pending order", async () => {
const claim = async () => ({ count: 1 });
await expect(claimTopupDelivery(claim)).resolves.toBeUndefined();
});
});
+84
View File
@@ -0,0 +1,84 @@
export interface PendingTopup {
userId: number;
status: string | null;
amount?: number;
}
export type AuthorizedTopup = PendingTopup & { action: "CAPTURE" | "DELIVER_CREDITS" };
export type FindTopupOrder = (orderId: string) => Promise<PendingTopup | null>;
export interface CreatedTopupRecord {
userId: number;
transactionId: string;
status: "CREATED";
description: string;
amount: number;
currency: string;
createdAt: Date;
updatedAt: Date;
}
export type CreateTopupRecord = (data: CreatedTopupRecord) => Promise<unknown>;
export class TopupCaptureError extends Error {
constructor(public readonly code: "ORDER_NOT_FOUND" | "ORDER_ALREADY_PROCESSED") {
super(code);
}
}
export class TopupDeliveryError extends Error {
readonly code = "DELIVERY_ALREADY_CLAIMED";
constructor() {
super("DELIVERY_ALREADY_CLAIMED");
}
}
export async function claimTopupDelivery(
claim: () => Promise<{ count: number }>,
): Promise<void> {
const result = await claim();
if (result.count !== 1) throw new TopupDeliveryError();
}
export async function authorizeTopupCapture(
userId: number,
orderId: string,
findOrder: FindTopupOrder,
): Promise<AuthorizedTopup> {
const order = await findOrder(orderId);
if (!order || order.userId !== userId) {
throw new TopupCaptureError("ORDER_NOT_FOUND");
}
if (order.status === "CAPTURED_PENDING_CREDIT" && Number.isFinite(order.amount)) {
return { ...order, action: "DELIVER_CREDITS" };
}
if (order.status !== "CREATED") {
throw new TopupCaptureError("ORDER_ALREADY_PROCESSED");
}
return { ...order, action: "CAPTURE" };
}
export async function recordCreatedTopup(
topup: {
userId: number;
orderId: string;
amount: number;
currency: string;
credits: number;
},
createRecord: CreateTopupRecord,
): Promise<void> {
const now = new Date();
await createRecord({
userId: topup.userId,
transactionId: topup.orderId,
status: "CREATED",
description: `Top-up: ${topup.credits} credits`,
amount: topup.amount,
currency: topup.currency,
createdAt: now,
updatedAt: now,
});
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
describe("createOwnedTicketReply", () => {
it("does not create a reply for a ticket owned by another user", async () => {
let created = false;
const db = {
findTicket: async () => ({ id: 5n, userId: 7 }),
createReply: async () => {
created = true;
throw new Error("must not run");
},
touchTicket: async () => undefined,
};
await expect(
createOwnedTicketReply(db, { ticketId: 5n, userId: 99, content: "Help" }),
).resolves.toBeNull();
expect(created).toBe(false);
});
it("creates the reply and updates ticket activity as one operation", async () => {
let touchedAt: Date | undefined;
const db = {
findTicket: async () => ({ id: 5n, userId: 99 }),
createReply: async (data: { createdAt: Date }) => ({
id: 8n,
userId: 99,
content: "Help",
createdAt: data.createdAt,
}),
touchTicket: async (_ticketId: bigint, updatedAt: Date) => {
touchedAt = updatedAt;
},
};
const reply = await createOwnedTicketReply(db, {
ticketId: 5n,
userId: 99,
content: "Help",
});
expect(reply).toMatchObject({ id: 8n, userId: 99, content: "Help" });
expect(touchedAt).toBe(reply?.createdAt);
});
});
+31
View File
@@ -0,0 +1,31 @@
export interface TicketReplyRecord {
id: bigint;
userId: number;
content: string;
createdAt: Date | null;
}
export interface TicketReplyDb {
findTicket(ticketId: bigint): Promise<{ id: bigint; userId: number | null } | null>;
createReply(data: {
ticketId: bigint;
userId: number;
content: string;
createdAt: Date;
updatedAt: Date;
}): Promise<TicketReplyRecord>;
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
}
export async function createOwnedTicketReply(
db: TicketReplyDb,
input: { ticketId: bigint; userId: number; content: string },
): Promise<TicketReplyRecord | null> {
const ticket = await db.findTicket(input.ticketId);
if (!ticket || ticket.userId !== input.userId) return null;
const now = new Date();
const reply = await db.createReply({ ...input, createdAt: now, updatedAt: now });
await db.touchTicket(input.ticketId, now);
return reply;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { contrastRatio, readableColor } from "@/lib/theme-contrast";
import { PRESETS } from "@/lib/theme-presets";
describe("contrastRatio", () => {
it("calculates the WCAG ratio for black on white", () => {
expect(contrastRatio("#000000", "#ffffff")).toBe(21);
});
it("returns null for unsupported color formats", () => {
expect(contrastRatio("var(--color-text)", "#ffffff")).toBeNull();
});
});
describe("built-in theme presets", () => {
it("derives WCAG-readable public foregrounds for every palette", () => {
for (const preset of Object.values(PRESETS)) {
const backgrounds = [preset.color_background, preset.color_surface];
const foregrounds = [
readableColor(preset.color_text, backgrounds),
readableColor(preset.color_text_muted, backgrounds),
readableColor(preset.color_primary, backgrounds),
readableColor(preset.color_accent, backgrounds),
];
for (const foreground of foregrounds) {
for (const background of backgrounds) {
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
}
}
const buttonText = readableColor(preset.button_text_color, [preset.button_primary_color]);
expect(contrastRatio(buttonText, preset.button_primary_color)).toBeGreaterThanOrEqual(4.5);
const primaryForeground = readableColor(preset.button_text_color, [preset.color_primary]);
expect(contrastRatio(primaryForeground, preset.color_primary)).toBeGreaterThanOrEqual(4.5);
}
});
it.each(["#22c55e", "#ef4444"])("derives readable text for default button %s", (background) => {
const foreground = readableColor("#ffffff", [background]);
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
});
});
describe("readableColor", () => {
it("keeps the preferred color when it passes on every background", () => {
expect(readableColor("#0f172a", ["#ffffff", "#f8fafc"], 4.5)).toBe("#0f172a");
});
it("replaces low-contrast golden text with a readable neutral", () => {
const color = readableColor("#f59e0b", ["#ffffff", "#f8fafc"], 4.5);
expect(color).toBe("#000000");
expect(contrastRatio(color, "#ffffff")).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(color, "#f8fafc")).toBeGreaterThanOrEqual(4.5);
});
it("chooses white text for a dark button when configured text fails", () => {
expect(readableColor("#777777", ["#111827"], 4.5)).toBe("#ffffff");
});
it("preserves unsupported custom colors instead of corrupting CSS", () => {
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)");
});
});
+47
View File
@@ -0,0 +1,47 @@
type Rgb = readonly [number, number, number];
export function contrastRatio(foreground: string, background: string): number | null {
const fg = parseHex(foreground);
const bg = parseHex(background);
if (!fg || !bg) return null;
const light = Math.max(relativeLuminance(fg), relativeLuminance(bg));
const dark = Math.min(relativeLuminance(fg), relativeLuminance(bg));
return (light + 0.05) / (dark + 0.05);
}
export function readableColor(
preferred: string,
backgrounds: readonly string[],
minimumRatio = 4.5,
): string {
const preferredRatios = backgrounds.map((background) => contrastRatio(preferred, background));
if (preferredRatios.some((ratio) => ratio === null)) return preferred;
if (preferredRatios.every((ratio) => (ratio as number) >= minimumRatio)) return preferred;
const candidates = ["#000000", "#ffffff"] as const;
return candidates
.map((color) => ({
color,
minimum: Math.min(...backgrounds.map((background) => contrastRatio(color, background) ?? 0)),
}))
.sort((a, b) => b.minimum - a.minimum)[0].color;
}
function parseHex(value: string): Rgb | null {
const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
if (!match) return null;
return [
Number.parseInt(match[1].slice(0, 2), 16),
Number.parseInt(match[1].slice(2, 4), 16),
Number.parseInt(match[1].slice(4, 6), 16),
];
}
function relativeLuminance(rgb: Rgb): number {
const [red, green, blue] = rgb.map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}