From 4a1e1115b301eef822a89817e95e6c1a9b2f0368 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 11 Jul 2026 20:12:10 +0200 Subject: [PATCH] Harden CMS security and theme contrast --- src/actions/admin-ads.ts | 6 +- src/actions/admin-applications.ts | 12 +-- src/actions/admin-articles.ts | 1 + src/actions/admin-email-templates.ts | 11 +-- src/actions/admin-help.ts | 11 ++- src/actions/admin-permissions.ts | 34 ++++++-- src/actions/admin-rare-values.ts | 16 ++-- src/actions/admin-shop.ts | 37 +++++---- src/actions/admin-vouchers.ts | 19 ++--- src/app/api/me/route.ts | 11 +-- src/app/api/me/tokens/route.ts | 18 +++-- src/app/api/paypal/capture/route.ts | 87 +++++++++++++++----- src/app/api/paypal/create/route.ts | 12 +++ src/app/api/shop/route.ts | 12 ++- src/app/api/tickets/[id]/reply/route.ts | 46 +++++------ src/app/api/tickets/[id]/route.ts | 6 +- src/app/api/tickets/route.ts | 6 +- src/app/api/tokens/route.ts | 5 +- src/app/api/values/route.ts | 12 ++- src/app/client/client-view.tsx | 4 +- src/app/globals.css | 67 +++++++++------ src/app/me/page.tsx | 2 +- src/app/news/page.tsx | 4 +- src/app/radio/layout.tsx | 4 +- src/app/u/[username]/page.tsx | 6 +- src/components/auth/discord-verify-form.tsx | 4 +- src/components/auth/register-form.tsx | 22 ++--- src/components/home/guest-view.tsx | 2 +- src/components/home/user-view.tsx | 6 +- src/components/language-switcher.tsx | 2 +- src/components/mobile-nav.tsx | 2 +- src/components/public/radio-player.tsx | 2 +- src/components/site-footer.tsx | 5 +- src/components/theme-vars.tsx | 48 ++++++++++- src/components/top-header.tsx | 8 +- src/lib/admin/guard.ts | 16 ++-- src/lib/admin/notice.test.ts | 24 ++++++ src/lib/admin/notice.ts | 26 ++++++ src/lib/admin/staff-user.test.ts | 20 +++++ src/lib/admin/staff-user.ts | 21 +++++ src/lib/api-auth.test.ts | 11 +++ src/lib/api-auth.ts | 9 +-- src/lib/api.test.ts | 48 +++++++++++ src/lib/api.ts | 28 +++++-- src/lib/auth/personal-token-scope.ts | 8 ++ src/lib/auth/session-user.test.ts | 28 +++++++ src/lib/auth/session-user.ts | 9 +++ src/lib/form-data.test.ts | 18 +++++ src/lib/form-data.ts | 6 ++ src/lib/server-log.test.ts | 29 +++++++ src/lib/server-log.ts | 39 +++++++++ src/lib/services/paypal-topup.test.ts | 90 +++++++++++++++++++++ src/lib/services/paypal-topup.ts | 84 +++++++++++++++++++ src/lib/services/ticket-replies.test.ts | 46 +++++++++++ src/lib/services/ticket-replies.ts | 31 +++++++ src/lib/theme-contrast.test.ts | 66 +++++++++++++++ src/lib/theme-contrast.ts | 47 +++++++++++ 57 files changed, 1023 insertions(+), 231 deletions(-) create mode 100644 src/lib/admin/notice.test.ts create mode 100644 src/lib/admin/notice.ts create mode 100644 src/lib/admin/staff-user.test.ts create mode 100644 src/lib/admin/staff-user.ts create mode 100644 src/lib/api-auth.test.ts create mode 100644 src/lib/api.test.ts create mode 100644 src/lib/auth/personal-token-scope.ts create mode 100644 src/lib/auth/session-user.test.ts create mode 100644 src/lib/auth/session-user.ts create mode 100644 src/lib/form-data.test.ts create mode 100644 src/lib/form-data.ts create mode 100644 src/lib/server-log.test.ts create mode 100644 src/lib/server-log.ts create mode 100644 src/lib/services/paypal-topup.test.ts create mode 100644 src/lib/services/paypal-topup.ts create mode 100644 src/lib/services/ticket-replies.test.ts create mode 100644 src/lib/services/ticket-replies.ts create mode 100644 src/lib/theme-contrast.test.ts create mode 100644 src/lib/theme-contrast.ts diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index 02d82b91..80869dca 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -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 { export async function deleteAd(formData: FormData): Promise { 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 } }); diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index a0da24c5..0bd9a9cd 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -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 { 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 } }); diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 9fdf77b4..f3e062b8 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -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 { const base = slugify(title); diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts index f5f6b59b..5036ab5e 100644 --- a/src/actions/admin-email-templates.ts +++ b/src/actions/admin-email-templates.ts @@ -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 { await requireStaff(); @@ -61,14 +62,8 @@ export async function updateEmailTemplate(formData: FormData): Promise { export async function deleteEmailTemplate(formData: FormData): Promise { 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"); } diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts index b0198420..0a5c72c0 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.ts @@ -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 { export async function updateHelpQuestion(formData: FormData): Promise { 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 { export async function deleteHelpQuestion(formData: FormData): Promise { 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 } }); diff --git a/src/actions/admin-permissions.ts b/src/actions/admin-permissions.ts index 7d95c7e5..0fcdd06d 100644 --- a/src/actions/admin-permissions.ts +++ b/src/actions/admin-permissions.ts @@ -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 { 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 { @@ -63,33 +69,45 @@ export async function updatePermission(formData: FormData): Promise { 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 { 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"); } diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index a05a7f4c..5cdc0bde 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -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 { await requireStaff(); @@ -28,9 +29,8 @@ export async function createCategory(formData: FormData): Promise { export async function deleteCategory(formData: FormData): Promise { 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 { export async function createValue(formData: FormData): Promise { 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 { export async function deleteValue(formData: FormData): Promise { 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 } }); diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts index fd0f9626..dfe1ff90 100644 --- a/src/actions/admin-shop.ts +++ b/src/actions/admin-shop.ts @@ -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 { 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 { export async function updateShopArticle(formData: FormData): Promise { 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 { 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 { export async function deleteShopArticle(formData: FormData): Promise { 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 { targetType: "shop_article", targetId: Number(id), }); - } catch { + } catch (error) { + logServerError("admin.shop_delete_failed", error, { + staffId: staff.id, + articleId: String(id), + }); return; } diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index 5908acc0..fa818b6b 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -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 { await requireStaff(); @@ -37,7 +39,8 @@ export async function createVoucher(formData: FormData): Promise { 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 { export async function deleteVoucher(formData: FormData): Promise { 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; } diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index ae4e4f36..4e184315 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -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"); } } diff --git a/src/app/api/me/tokens/route.ts b/src/app/api/me/tokens/route.ts index 1cf70a0e..b122d0d5 100644 --- a/src/app/api/me/tokens/route.ts +++ b/src/app/api/me/tokens/route.ts @@ -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 { 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"); } } diff --git a/src/app/api/paypal/capture/route.ts b/src/app/api/paypal/capture/route.ts index ec118a76..3c62c50c 100644 --- a/src/app/api/paypal/capture/route.ts +++ b/src/app/api/paypal/capture/route.ts @@ -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 { 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 { 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 { 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 { // 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 { 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 { ); } - // 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( diff --git a/src/app/api/paypal/create/route.ts b/src/app/api/paypal/create/route.ts index 99449eec..0ad4e101 100644 --- a/src/app/api/paypal/create/route.ts +++ b/src/app/api/paypal/create/route.ts @@ -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 { 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 { ); } + 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, diff --git a/src/app/api/shop/route.ts b/src/app/api/shop/route.ts index 28cd9bb6..e56bdf6c 100644 --- a/src/app/api/shop/route.ts +++ b/src/app/api/shop/route.ts @@ -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= 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 { diff --git a/src/app/api/tickets/[id]/reply/route.ts b/src/app/api/tickets/[id]/reply/route.ts index e8b6e084..2c9a620a 100644 --- a/src/app/api/tickets/[id]/reply/route.ts +++ b/src/app/api/tickets/[id]/reply/route.ts @@ -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( { diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index f09ce6d4..db6d96b0 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -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({ diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts index 1e8f594a..6070ddf6 100644 --- a/src/app/api/tickets/route.ts +++ b/src/app/api/tickets/route.ts @@ -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 { diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index af735edc..01672fae 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -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); } diff --git a/src/app/api/values/route.ts b/src/app/api/values/route.ts index 62b9405e..de6e0c76 100644 --- a/src/app/api/values/route.ts +++ b/src/app/api/values/route.ts @@ -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= 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= 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 { diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx index 95bb07ba..5080bbde 100644 --- a/src/app/client/client-view.tsx +++ b/src/app/client/client-view.tsx @@ -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" > diff --git a/src/app/globals.css b/src/app/globals.css index 25d5f1f9..14e6c430 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index cdc487af..562374fc 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -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)", diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index 45d79d61..f64a9dd1 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -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() {

)} -

+

Open Discord, ga naar Instellingen → Geavanceerd → Ontwikkelaarsmodus (aan). Klik met rechts op je eigen naam en kies ID kopiëren. Plak dat hier:

@@ -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 diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index c46acb27..e95aba4d 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -76,7 +76,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) { @@ -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) { @@ -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)", }} /> -

+

Geen e-mail? Je kunt later via Discord verifiëren.

@@ -132,7 +132,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) { @@ -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) { @@ -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) { setTermsAccepted(!termsAccepted)} > {t("termsAccept", { hotel: hotelName })} @@ -242,7 +242,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) { {t("alreadyHaveAccount")} diff --git a/src/components/home/guest-view.tsx b/src/components/home/guest-view.tsx index d41e7e36..d414ade2 100644 --- a/src/components/home/guest-view.tsx +++ b/src/components/home/guest-view.tsx @@ -156,7 +156,7 @@ export default async function GuestView() {
{articles.length === 0 ? ( -

+

{t("noArticles")}

) : ( diff --git a/src/components/home/user-view.tsx b/src/components/home/user-view.tsx index 6de8c937..34e6cf92 100644 --- a/src/components/home/user-view.tsx +++ b/src/components/home/user-view.tsx @@ -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 ? (

{t("noFriendsOnline")}

@@ -185,7 +185,7 @@ export default async function UserView({ userId, username, look }: UserViewProps

{latestArticle.title}

{latestArticle.shortStory && ( -

+

{excerpt(latestArticle.shortStory, 100)}

)} diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 4401fd74..a5c67f8f 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -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", diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index 2e77b6a5..60d8a029 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -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} diff --git a/src/components/public/radio-player.tsx b/src/components/public/radio-player.tsx index 30f65f75..49496f6c 100644 --- a/src/components/public/radio-player.tsx +++ b/src/components/public/radio-player.tsx @@ -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, }} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 4ca3718c..26328a02 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -9,7 +9,10 @@ export async function SiteFooter() { return (