- Legal: /privacy-policy and /datadeletion (shared LegalPageLayout, footer links, sitemap + integration tests, NEXT_PUBLIC_LEGAL_CONTACT_EMAIL). - Threads OAuth: server-side /api/threads-exchange and /threads-callback page (Suspense + client component, noindex, no leaked secrets). - Username + live wallet pill in top nav. New NavWalletBalance component polls /api/wallet, refreshes on tab focus, and listens to the `wallet:refresh` event bus so cash-outs and refunds update the nav in real time. Flash animation on balance changes. - useLiveWalletBalance hook now broadcasts `wallet:refresh` after every fetch so games, exchange panel, wallet actions, and nav all stay in sync without extra polling. - PvP fund-locking (`POST /api/games/rooms`): creator funds debited atomically with room creation; ledger entry tagged with `gameRoomId`. Joiner debit happens at join. Old double-debit of the creator is gone. - DELETE /api/games/rooms?id=... lets a creator cancel a WAITING room and get an idempotent refund. Coin Flip + Pong waiting screens show a Cancel & Refund button. - Pong/Coin Flip recovery: expiry sweep + boot-time `recoverOrphaned RoomsOnBoot()` (runs before listen()) refund both parties for any ACTIVE/expired rooms so a server restart never strands locked credits. - Schema migration `20260520000000_game_ledger_links` adds optional `gameSessionId` + `gameRoomId` FKs to LedgerEntry (with indexes) and extra indexes on GameSession/GameRoom for resume + sweep queries. - GET /api/games/active returns a user's active solo session + open rooms (sanitized — no mine/bomb positions). Mines and Tower clients rehydrate on mount so a refresh mid-round resumes instead of dropping. - ActiveGamesBanner surfaces unfinished rounds on /wallet and /casino with Resume / Rejoin / Cancel & refund actions. - ExchangePanel unified with useLiveWalletBalance; per-game header gets an "Open wallet →" chip; Dice clears stale result on roll; Blackjack reveals full dealer hand on natural blackjack/push; Mines refund label fixed; Tower final multiplier fixed; GameHistory error path; Prediction "Resolved" tab. - Site audit + redmeFIXES triage notes (REDME-FIXSES-TRIAGE.txt, SITE-AUDIT.txt). Co-authored-by: Cursor <cursoragent@cursor.com>
83 lines
2.4 KiB
TypeScript
83 lines
2.4 KiB
TypeScript
import NextAuth from "next-auth";
|
|
import Credentials from "next-auth/providers/credentials";
|
|
import bcrypt from "bcryptjs";
|
|
import { z } from "zod";
|
|
import {
|
|
isEmailShape,
|
|
normalizeEmail,
|
|
normalizeUsername,
|
|
USERNAME_RE,
|
|
} from "@/lib/account-identifiers";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { authConfig } from "./auth.config";
|
|
|
|
const credentialsSchema = z.object({
|
|
email: z.string().trim().min(1),
|
|
password: z.string().min(1),
|
|
});
|
|
|
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
...authConfig,
|
|
providers: [
|
|
Credentials({
|
|
name: "Credentials",
|
|
credentials: {
|
|
email: { label: "Email or username", type: "text" },
|
|
password: { label: "Password", type: "password" },
|
|
},
|
|
async authorize(raw) {
|
|
const parsed = credentialsSchema.safeParse(raw);
|
|
if (!parsed.success) return null;
|
|
|
|
const identifier = parsed.data.email;
|
|
const password = parsed.data.password;
|
|
|
|
let user = null;
|
|
if (isEmailShape(identifier)) {
|
|
const emailLookup = normalizeEmail(identifier);
|
|
user = await prisma.user.findFirst({
|
|
where: { email: { equals: emailLookup, mode: "insensitive" } },
|
|
});
|
|
} else {
|
|
const loginName = normalizeUsername(identifier);
|
|
if (!USERNAME_RE.test(loginName)) return null;
|
|
user = await prisma.user.findUnique({ where: { username: loginName } });
|
|
}
|
|
|
|
if (!user?.passwordHash) return null;
|
|
|
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
|
if (!ok) return null;
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name ?? undefined,
|
|
username: user.username,
|
|
role: user.role,
|
|
};
|
|
},
|
|
}),
|
|
],
|
|
callbacks: {
|
|
jwt({ token, user }) {
|
|
if (user) {
|
|
token.id = user.id;
|
|
if (user.username) token.username = user.username;
|
|
const role = (user as { role?: string }).role;
|
|
token.role = role === "ADMIN" || role === "USER" ? role : "USER";
|
|
}
|
|
return token;
|
|
},
|
|
session({ session, token }) {
|
|
if (session.user) {
|
|
session.user.id = (token.id as string) ?? (token.sub as string);
|
|
if (typeof token.username === "string") session.user.username = token.username;
|
|
const role = token.role;
|
|
session.user.role = role === "ADMIN" || role === "USER" ? role : "USER";
|
|
}
|
|
return session;
|
|
},
|
|
},
|
|
});
|