From 08a9882be8dad855c41609528e23742cd6602497 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 28 Jun 2026 13:52:32 +0200 Subject: [PATCH] Add register + logout auth flows, and batch-5 resources Auth (hand-built, uses the auth core): - /register + register() action: validates, hashes with argon2id (hashPassword), creates an emulator-compatible users row (accountCreated/ipRegister/ipCurrent/ look), redirect kept outside try so NEXT_REDIRECT propagates. Login/register cross-links; header shows Register (logged-out) and a Logout (signOut) button. Batch 5 (parallel agents): /admin/rooms (+[id]) search+detail, /admin/vouchers (CRUD), /admin/subscriptions (read), /admin/calendar (campaigns+rewards read), /marketplace (public). Header + admin nav extended. Verified: tsc exit 0, vitest 48/48, next build exit 0 (52 page routes). --- src/actions/admin-vouchers.ts | 66 +++++++++++ src/actions/register.ts | 61 ++++++++++ src/app/admin/calendar/[id]/page.tsx | 168 +++++++++++++++++++++++++++ src/app/admin/calendar/page.tsx | 99 ++++++++++++++++ src/app/admin/layout.tsx | 4 + src/app/admin/rooms/[id]/page.tsx | 76 ++++++++++++ src/app/admin/rooms/page.tsx | 128 ++++++++++++++++++++ src/app/admin/subscriptions/page.tsx | 74 ++++++++++++ src/app/admin/vouchers/page.tsx | 123 ++++++++++++++++++++ src/app/login/page.tsx | 4 + src/app/marketplace/page.tsx | 133 +++++++++++++++++++++ src/app/register/page.tsx | 43 +++++++ src/components/site-header.tsx | 26 ++++- 13 files changed, 1001 insertions(+), 4 deletions(-) create mode 100644 src/actions/admin-vouchers.ts create mode 100644 src/actions/register.ts create mode 100644 src/app/admin/calendar/[id]/page.tsx create mode 100644 src/app/admin/calendar/page.tsx create mode 100644 src/app/admin/rooms/[id]/page.tsx create mode 100644 src/app/admin/rooms/page.tsx create mode 100644 src/app/admin/subscriptions/page.tsx create mode 100644 src/app/admin/vouchers/page.tsx create mode 100644 src/app/marketplace/page.tsx create mode 100644 src/app/register/page.tsx diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts new file mode 100644 index 00000000..fbd3fa89 --- /dev/null +++ b/src/actions/admin-vouchers.ts @@ -0,0 +1,66 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; + +export async function createVoucher(formData: FormData): Promise { + await requireStaff(); + + const code = String(formData.get("code") ?? "").trim().slice(0, 255); + const amount = Number(formData.get("amount")); + const maxUsesRaw = Number(formData.get("maxUses")); + const maxUses = Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1; + + if (!code || !(amount > 0)) return; + + const expiresRaw = String(formData.get("expiresAt") ?? "").trim(); + let expiresAt: Date | null = null; + if (expiresRaw) { + const parsed = new Date(expiresRaw); + if (!Number.isNaN(parsed.getTime())) expiresAt = parsed; + } + + const now = new Date(); + + try { + await prisma.websiteShopVouchers.create({ + data: { + code, + amount: Math.floor(amount), + maxUses, + useCount: 0, + expiresAt, + createdAt: now, + updatedAt: now, + }, + }); + } catch { + // Unique constraint on `code` (or DB unavailable) — swallow and re-render. + return; + } + + revalidatePath("/admin/vouchers"); +} + +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; + } + + try { + await prisma.websiteShopVouchers.delete({ where: { id } }); + } catch { + return; + } + + revalidatePath("/admin/vouchers"); +} diff --git a/src/actions/register.ts b/src/actions/register.ts new file mode 100644 index 00000000..d7b507f5 --- /dev/null +++ b/src/actions/register.ts @@ -0,0 +1,61 @@ +"use server"; + +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { hashPassword } from "@/lib/auth/password"; +import { prisma } from "@/lib/prisma"; + +const USERNAME_RE = /^[A-Za-z0-9_\-=?!@:.,]{3,25}$/; +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +// A valid starter Habbo figure so the avatar renders in-client immediately. +const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; + +export async function register(formData: FormData): Promise { + const username = String(formData.get("username") ?? "").trim(); + const mail = String(formData.get("mail") ?? "").trim().toLowerCase(); + const password = String(formData.get("password") ?? ""); + + let error: string | null = null; + if (!USERNAME_RE.test(username)) error = "Username must be 3-25 valid characters"; + else if (password.length < 6) error = "Password must be at least 6 characters"; + else if (!EMAIL_RE.test(mail)) error = "Enter a valid email address"; + + // Uniqueness check (kept out of the success path's try so NEXT_REDIRECT propagates). + if (!error) { + try { + const existing = await prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }); + if (existing) error = "That username is already taken"; + } catch { + error = "Registration is temporarily unavailable"; + } + } + + if (!error) { + const h = await headers(); + const ip = + h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0"; + const now = Math.floor(Date.now() / 1000); + try { + await prisma.user.create({ + data: { + username, + password: await hashPassword(password), + mail, + accountCreated: now, + ipRegister: ip, + ipCurrent: ip, + look: DEFAULT_LOOK, + }, + }); + } catch { + error = "Could not create the account (is the username unique?)"; + } + } + + // redirect() throws NEXT_REDIRECT — must be OUTSIDE any try/catch. + if (error) redirect(`/register?error=${encodeURIComponent(error)}`); + redirect("/login?registered=1"); +} diff --git a/src/app/admin/calendar/[id]/page.tsx b/src/app/admin/calendar/[id]/page.tsx new file mode 100644 index 00000000..fa936da1 --- /dev/null +++ b/src/app/admin/calendar/[id]/page.tsx @@ -0,0 +1,168 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type Campaign = { + id: number; + name: string; + image: string; + startTimestamp: number; + totalDays: number; + lockExpired: string; + enabled: string; +}; + +type Reward = { + id: number; + campaignId: number; + productName: string; + customImage: string; + credits: number; + pixels: number; + points: number; + pointsType: number; + badge: string; + itemId: number; + subscriptionType: string | null; + subscriptionDays: number; +}; + +function fmtDate(ts: number): string { + if (!ts) return "—"; + return new Date(ts * 1000).toISOString().slice(0, 10); +} + +export default async function AdminCalendarCampaign({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const campaignId = Number(id); + if (!Number.isInteger(campaignId)) notFound(); + + let campaign: Campaign | null = null; + try { + campaign = await prisma.calendarCampaigns.findUnique({ + where: { id: campaignId }, + select: { + id: true, + name: true, + image: true, + startTimestamp: true, + totalDays: true, + lockExpired: true, + enabled: true, + }, + }); + } catch { + campaign = null; + } + if (!campaign) notFound(); + + let rewards: Reward[] = []; + try { + rewards = await prisma.calendarRewards.findMany({ + where: { campaignId }, + select: { + id: true, + campaignId: true, + productName: true, + customImage: true, + credits: true, + pixels: true, + points: true, + pointsType: true, + badge: true, + itemId: true, + subscriptionType: true, + subscriptionDays: true, + }, + orderBy: { id: "asc" }, + take: 500, + }); + } catch { + rewards = []; + } + + const disabled = campaign.enabled !== "1"; + + return ( +
+

+ ← Calendar campaigns +

+ +
+ {campaign.image ? ( + // eslint-disable-next-line @next/next/no-img-element + {campaign.name} + ) : null} +
+

{campaign.name || `Campaign #${campaign.id}`}

+

+ #{campaign.id} · {campaign.totalDays} day{campaign.totalDays === 1 ? "" : "s"} · starts{" "} + {fmtDate(campaign.startTimestamp)} + {disabled ? " · Disabled" : " · Enabled"} + {campaign.lockExpired === "1" ? " · Locks expired days" : ""} +

+
+
+ +

Rewards

+ {rewards.length === 0 ? ( +

No rewards configured for this campaign.

+ ) : ( + + + + + + + + + + + + + + + {rewards.map((r) => ( + + + + + + + + + + + ))} + +
IDProductItemCreditsPixelsPointsBadgeSubscription
{r.id}{r.productName || —}{r.itemId > 0 ? r.itemId : "—"}{r.credits > 0 ? r.credits : —}{r.pixels > 0 ? r.pixels : —} + {r.points > 0 ? ( + `${r.points} (type ${r.pointsType})` + ) : ( + — + )} + {r.badge || "—"} + {r.subscriptionType + ? `${r.subscriptionType} · ${r.subscriptionDays}d` + : "—"} +
+ )} + +

+ {rewards.length} reward{rewards.length === 1 ? "" : "s"} +

+
+ ); +} diff --git a/src/app/admin/calendar/page.tsx b/src/app/admin/calendar/page.tsx new file mode 100644 index 00000000..0244f797 --- /dev/null +++ b/src/app/admin/calendar/page.tsx @@ -0,0 +1,99 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type Campaign = { + id: number; + name: string; + image: string; + startTimestamp: number; + totalDays: number; + lockExpired: string; + enabled: string; +}; + +function fmtDate(ts: number): string { + if (!ts) return "—"; + return new Date(ts * 1000).toISOString().slice(0, 10); +} + +export default async function AdminCalendar() { + let campaigns: Campaign[] = []; + try { + campaigns = await prisma.calendarCampaigns.findMany({ + select: { + id: true, + name: true, + image: true, + startTimestamp: true, + totalDays: true, + lockExpired: true, + enabled: true, + }, + orderBy: { id: "desc" }, + }); + } catch { + campaigns = []; + } + + // Tally reward counts per campaign in JS (avoid prisma.groupBy). + const rewardCounts = new Map(); + try { + const rewards = await prisma.calendarRewards.findMany({ + select: { campaignId: true }, + }); + for (const r of rewards) { + rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1); + } + } catch { + // leave map empty on failure + } + + return ( +
+

Calendar campaigns

+

+ Read-only · {campaigns.length} campaign{campaigns.length === 1 ? "" : "s"}. +

+ + {campaigns.length === 0 ? ( +

No calendar campaigns found.

+ ) : ( +
+ {campaigns.map((c) => { + const disabled = c.enabled !== "1"; + const count = rewardCounts.get(c.id) ?? 0; + return ( + + {c.image ? ( + // eslint-disable-next-line @next/next/no-img-element + {c.name} + ) : ( +
+ )} +
+

{c.name || `Campaign #${c.id}`}

+

+ #{c.id} · {c.totalDays} day{c.totalDays === 1 ? "" : "s"} · {count} reward + {count === 1 ? "" : "s"} +

+

+ Starts {fmtDate(c.startTimestamp)} + {disabled ? " · Disabled" : ""} + {c.lockExpired === "1" ? " · Locks expired" : ""} +

+
+ + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index d3140092..84641d8a 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -16,11 +16,15 @@ export default async function AdminLayout({ children }: { children: ReactNode })