Files
democratic-money/src/auth.ts
root 7eac42e820 Legal pages, Threads OAuth, live wallet pill, PvP money flow + resume
- 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>
2026-05-20 06:43:26 +00:00

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;
},
},
});