Harden CMS security and theme contrast
This commit is contained in:
1 parent
2465ff2170
commit
4a1e1115b3
57 files changed
+1023
-231
No files matched your search
@@ -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,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 } });
|
||||
|
||||
@@ -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,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 +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 } });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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" })}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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: '["*"]',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user