28 Commits
Author SHA1 Message Date
Michilis 6ec8b8400f Merge pull request 'dev' (#28) from dev into main
Reviewed-on: #28
2026-07-26 05:38:23 +00:00
Michilis 19461098a0 Merge pull request 'Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.' (#27) from tpago-fix into main
Reviewed-on: #27
2026-07-20 03:55:01 +00:00
Michilis 74dcc8e5ac Merge pull request 'Normalize RUC formatting to base-checkdigit across booking and admin views.' (#26) from RUC-fix into main
Reviewed-on: #26
2026-07-13 03:48:13 +00:00
Michilis bd1043a3f4 Merge pull request 'speed' (#25) from speed into main
Reviewed-on: #25
2026-07-13 01:18:23 +00:00
Michilis 107cd9753e Merge pull request 'Redesign user dashboard with overview tab and i18n payment copy.' (#24) from dashboard-update into main
Reviewed-on: #24
2026-06-29 17:00:15 +00:00
Michilis 194604004e Merge pull request 'security-update' (#23) from security-update into main
Reviewed-on: #23
2026-06-29 06:47:19 +00:00
Michilis bb920ce6f0 Merge pull request 'Add per-quantity TPago payment links for multi-ticket checkout.' (#22) from tpago-update into main
Reviewed-on: #22
2026-06-19 00:24:00 +00:00
Michilis c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis 1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
Michilis 91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
Michilis a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
Michilis f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis 15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis 194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
Michilis 833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
Michilis ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
Michilis 3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
Michilis 8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
133 changed files with 3186 additions and 11601 deletions
+18 -70
View File
@@ -4,24 +4,17 @@ A full-stack web app for organizing and managing language exchange events (Asunc
## Features
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
- Stable `/next` and `/featured` URLs that redirect to the current event
- Human-readable event URL slugs (with legacy-ID redirect support)
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
- Lightning invoice reuse and re-payment straight from the dashboard
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
- One unified ticket-creation modal with first-class payment status
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
- **User dashboard**: profile, tickets, payments, sessions/security
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
## Tech stack
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
- **Email**: `nodemailer` (SMTP) with optional provider config
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
## Local development
@@ -29,8 +22,6 @@ A full-stack web app for organizing and managing language exchange events (Asunc
- Node.js 18+
- npm
- Go 1.26+ (for the `photo-api` service)
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
### Setup
@@ -38,14 +29,12 @@ A full-stack web app for organizing and managing language exchange events (Asunc
npm install
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
```
### Initialize database (SQLite by default)
```bash
npm run db:migrate # backend (Drizzle) tables
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
npm run db:migrate
```
### Run
@@ -54,13 +43,10 @@ npm run migrate:photos # photo-api owns only the photos_* tables via its own mi
npm run dev
```
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
Default URLs:
- Frontend: `http://localhost:3002`
- Backend API: `http://localhost:3001`
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
- API docs: `http://localhost:3001/api-docs`
### First user becomes admin
@@ -72,37 +58,16 @@ The first user to register becomes the **admin**. Register at `/register`.
Run these from the repo root:
```bash
npm run dev # backend + frontend + photo-api
npm run build # build all workspaces
npm run dev
npm run build
npm run start
npm run db:generate
npm run db:migrate
npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
# Photo service (Go)
npm run dev:photos # go run ./cmd/photo-api
npm run build:photos # go build -o bin/photo-api
npm run migrate:photos # apply photos_* migrations
npm run test:photos # go test ./...
# Move the photo library between storage backends (both must be configured
# in photo-api/.env; the source is left untouched, reruns skip what is there)
npm run sync:photos:to-s3 # local disk -> S3
npm run sync:photos:to-local # S3 -> local disk
npm run sync:photos -- to-s3 --dry-run # flags: --dry-run --overwrite
# --concurrency=N --gallery=<id>
# Uploads are deduplicated per gallery by content hash. Photos uploaded before
# that existed need hashing once (idempotent, deletes nothing):
npm run backfill:photos:checksums
npm run db:export # Backup database
npm run db:import # Restore from backup
```
After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and
restart the service to serve from the new backend. See
[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends).
You can also run per workspace:
```bash
@@ -117,34 +82,20 @@ npm run dev --workspace=frontend
Key settings (see `backend/.env.example` for the full list):
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
- **Auth**: `JWT_SECRET` (change in production)
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
### Photo service (`photo-api/.env`)
Key settings (see `photo-api/.env.example`):
- **Port**: `PORT=3003` (dev)
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
### Frontend (`frontend/.env`)
Key settings (see `frontend/.env.example`):
- **Server port**: `PORT=3002`
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
- Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
- Inlined at build time: changing it requires a rebuild, not just a restart.
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
- Leave empty to use same-origin `/api` (recommended when running behind nginx)
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
## Database
@@ -192,13 +143,12 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
This repo includes example configs in `deploy/`:
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
- Backend needs write access to `backend/data` and `backend/uploads`
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
- **nginx**:
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
Typical production flow:
@@ -206,9 +156,7 @@ Typical production flow:
```bash
npm ci
npm run build
npm run build:photos
npm run db:migrate
npm run migrate:photos
```
Then install/enable the systemd services and nginx configs for your server.
+1 -29
View File
@@ -50,41 +50,13 @@ DATABASE_URL=./data/spanglish.db
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
# S3_FORCE_PATH_STYLE=true
# Better Auth session secret. REQUIRED in production, 32+ characters.
# Generate one with: openssl rand -base64 48
# Rotating it signs everyone out (cookie signatures invalidate).
BETTER_AUTH_SECRET=
# Public site origin Better Auth builds its URLs against (falls back to
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
BETTER_AUTH_URL=
# Session cookie domain, shared across subdomains. Set this when the API is served
# from a different host than the site (e.g. api.spanglishcommunity.com vs
# spanglishcommunity.com): without it the cookie is host-only and the frontend's
# Next middleware cannot see it, so /dashboard and /admin bounce back to /login.
# Must start with a dot. Leave EMPTY in development (localhost is single-host).
# E.g. .spanglishcommunity.com
AUTH_COOKIE_DOMAIN=
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
# trusted; anything else is treated as a client and rate-limited by its
# actual socket address.
# TRUSTED_PROXIES=
# DEPRECATED: no longer used for API auth (Better Auth replaced the JWTs).
# Still read by photo-api as the fallback secret for gallery view tokens
# until PHOTO_VIEW_SECRET is set there; safe to remove after that.
# JWT Secret (change in production!)
JWT_SECRET=your-super-secret-key-change-in-production
# Google OAuth (optional - for Google Sign-In)
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
# Note: The same Client ID should be used in frontend/.env
GOOGLE_CLIENT_ID=
# Only needed for the redirect OAuth flow; the Google Identity Services
# button (ID token sign-in) works with the Client ID alone.
GOOGLE_CLIENT_SECRET=
# Server Configuration
PORT=3001
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

+3 -4
View File
@@ -20,10 +20,9 @@
"@hono/zod-openapi": "^0.14.4",
"argon2": "^0.44.0",
"bcryptjs": "^2.4.3",
"better-auth": "1.6.25",
"better-sqlite3": "^12.11.1",
"better-sqlite3": "^11.0.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.2",
"drizzle-orm": "^0.31.2",
"hono": "^4.4.7",
"ioredis": "^5.11.1",
"jose": "^5.4.0",
@@ -43,7 +42,7 @@
"@types/pdfkit": "^0.17.4",
"@types/pg": "^8.11.6",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.31.10",
"drizzle-kit": "^0.22.8",
"ioredis-mock": "^8.13.1",
"tsx": "^4.15.7",
"typescript": "^5.5.2",
-200
View File
@@ -1,200 +0,0 @@
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
import {
pgTable,
uuid,
varchar,
text as pgText,
timestamp,
boolean as pgBoolean,
bigint,
customType as pgCustomType,
} from 'drizzle-orm/pg-core';
// Better Auth table definitions for both dialects.
//
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
// foreign key that references them) survive the auth migration untouched. Only
// the columns Better Auth reads/writes are declared here; legacy columns
// (password, google_id, token_version) stay physically present but invisible
// to Better Auth. The full application-facing definition lives in schema.ts —
// two Drizzle table objects can safely describe the same SQL table.
const dbType = process.env.DB_TYPE || 'sqlite';
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
dataType() {
return 'text';
},
toDriver(value: Date): string {
return (value instanceof Date ? value : new Date(value)).toISOString();
},
fromDriver(value: string): Date {
return new Date(value);
},
});
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
dataType() {
return 'integer';
},
toDriver(value: boolean): number {
return value ? 1 : 0;
},
fromDriver(value: number | boolean): boolean {
return Boolean(value);
},
});
// ==================== SQLite ====================
export const sqliteAuthUsers = sqliteTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
createdAt: isoText('created_at').notNull(),
updatedAt: isoText('updated_at').notNull(),
// admin plugin fields
role: text('role').notNull().default('user'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
// application additionalFields
phone: text('phone'),
languagePreference: text('language_preference'),
rucNumber: text('ruc_number'),
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
accountStatus: text('account_status').notNull().default('active'),
});
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
// admin plugin (impersonation)
impersonatedBy: text('impersonated_by'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
id: text('id').primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('last_request'),
});
// ==================== PostgreSQL ====================
export const pgAuthUsers = pgTable('users', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
// admin plugin fields
role: varchar('role', { length: 20 }).notNull().default('user'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
// application additionalFields
phone: varchar('phone', { length: 50 }),
languagePreference: varchar('language_preference', { length: 10 }),
rucNumber: varchar('ruc_number', { length: 15 }),
isClaimed: pgIntBool('is_claimed').notNull(),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
});
export const pgAuthSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
token: varchar('token', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: pgText('user_agent'),
// admin plugin (impersonation)
impersonatedBy: uuid('impersonated_by'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthAccounts = pgTable('auth_accounts', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
accountId: varchar('account_id', { length: 255 }).notNull(),
providerId: varchar('provider_id', { length: 100 }).notNull(),
accessToken: pgText('access_token'),
refreshToken: pgText('refresh_token'),
idToken: pgText('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: pgText('scope'),
password: pgText('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthVerifications = pgTable('auth_verifications', {
id: uuid('id').primaryKey(),
identifier: varchar('identifier', { length: 255 }).notNull(),
value: pgText('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
id: varchar('id', { length: 64 }).primaryKey(),
key: varchar('key', { length: 255 }),
count: bigint('count', { mode: 'number' }),
lastRequest: bigint('last_request', { mode: 'number' }),
});
// ==================== Runtime-switched exports ====================
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
-100
View File
@@ -1,100 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import Database from 'better-sqlite3';
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
// rows, run migrate repeatedly, and assert the backfill is correct and never
// duplicates.
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
const dbPath = join(dir, 'migrate.db');
function runMigrate() {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: {
...process.env,
DB_TYPE: 'sqlite',
DATABASE_URL: dbPath,
REDIS_URL: '',
},
stdio: 'pipe',
});
}
let db: Database.Database;
beforeAll(() => {
// First run creates the schema
runMigrate();
db = new Database(dbPath);
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
// google_id links Google, '' marks guest accounts, suspended via status.
const now = new Date().toISOString();
const insert = db.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
);
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
// Second run performs the backfill against the seeded rows
runMigrate();
}, 240_000);
describe('Better Auth migration backfill', () => {
it('creates credential accounts for users with real passwords only', () => {
const rows = db
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
.all() as any[];
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
expect(byUser.get('legacy-both')).toContain('$argon2id$');
expect(byUser.get('legacy-suspended')).toBeTruthy();
// Guests ('' password) and Google-only users get no credential account
expect(byUser.has('legacy-guest')).toBe(false);
expect(byUser.has('legacy-google')).toBe(false);
});
it('creates google accounts from google_id', () => {
const rows = db
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
.all() as any[];
expect(rows).toEqual([
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
]);
});
it('marks claimed legacy users email-verified, guests not', () => {
const verified = (email: string) =>
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
expect(verified('argon@old.py')).toBe(1);
expect(verified('google@old.py')).toBe(1);
expect(verified('guest@old.py')).toBe(0);
});
it('mirrors suspended accounts to banned', () => {
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
expect(row.banned).toBe(1);
expect(row.ban_reason).toContain('suspended');
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
expect(active.banned).toBe(0);
});
it('is idempotent: a third run adds nothing', () => {
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
const before = count();
runMigrate();
expect(count()).toBe(before);
}, 60_000);
});
+5 -333
View File
@@ -1,6 +1,6 @@
import 'dotenv/config';
import { db, dbAll, dbGet, events, users } from './index.js';
import { sql, eq, ne } from 'drizzle-orm';
import { db, dbAll, events } from './index.js';
import { sql, eq } from 'drizzle-orm';
import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite';
@@ -133,25 +133,17 @@ async function migrate() {
`);
await (db as any).run(sql`
-- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone
-- shape only survives in databases created before the split into
-- first/last name, where the ALTERs below relaxed it; a fresh database
-- must not recreate constraints the app no longer satisfies (door
-- walk-ins have neither an email nor a phone).
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
event_id TEXT NOT NULL REFERENCES events(id),
attendee_first_name TEXT NOT NULL,
attendee_last_name TEXT,
attendee_email TEXT,
attendee_phone TEXT,
attendee_ruc TEXT,
attendee_name TEXT NOT NULL,
attendee_email TEXT NOT NULL,
attendee_phone TEXT NOT NULL,
preferred_language TEXT,
status TEXT NOT NULL DEFAULT 'pending',
checkin_at TEXT,
qr_code TEXT,
admin_note TEXT,
created_at TEXT NOT NULL
)
`);
@@ -267,25 +259,6 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
scope TEXT NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TEXT,
created_at TEXT NOT NULL
)
`);
// Invoices table
await (db as any).run(sql`
@@ -571,81 +544,6 @@ async function migrate() {
updated_by TEXT REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table).
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
impersonated_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id TEXT PRIMARY KEY,
key TEXT,
count INTEGER,
last_request INTEGER
)
`);
} else {
// PostgreSQL migrations
await (db as any).execute(sql`
@@ -863,25 +761,6 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key VARCHAR(128) PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
)
`);
// Invoices table
await (db as any).execute(sql`
@@ -1165,80 +1044,6 @@ async function migrate() {
updated_by UUID REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
impersonated_by UUID,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(100) NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
password TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id UUID PRIMARY KEY,
identifier VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id VARCHAR(64) PRIMARY KEY,
key VARCHAR(255),
count BIGINT,
last_request BIGINT
)
`);
}
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
@@ -1249,15 +1054,8 @@ async function migrate() {
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`,
`CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
];
for (const stmt of indexStatements) {
try {
@@ -1269,132 +1067,6 @@ async function migrate() {
} catch (e) { /* index may already exist */ }
}
// ==================== Better Auth data backfill ====================
// Idempotent: every statement is guarded so re-running migrate is safe, and
// legacy users are distinguished from Better-Auth-created users by having
// users.password / users.google_id set (Better Auth never writes either).
if (dbType === 'sqlite') {
// Legacy password hashes -> credential accounts (argon2 and bcrypt hashes
// both stay valid via the custom password verifier in lib/betterAuth.ts)
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password,
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
// Legacy Google links -> google provider accounts
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google',
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
// Claimed legacy accounts proved their email (register/claim link/Google)
await (db as any).run(sql`
UPDATE users SET email_verified = 1
WHERE email_verified = 0 AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
// Suspended -> banned (admin plugin field); users.ts keeps them in sync
await (db as any).run(sql`
UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = 0
`);
} else {
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW()
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW()
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
await (db as any).execute(sql`
UPDATE users SET email_verified = TRUE
WHERE email_verified = FALSE AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
await (db as any).execute(sql`
UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = FALSE
`);
}
// ==================== users.email normalization ====================
// Better Auth lowercases the address on every lookup and write it performs,
// but the users.email unique index is case-sensitive on both dialects. Rows
// written outside Better Auth (guest bookings, door sales, admin-added
// tickets) used to keep the address exactly as typed, so a buyer who entered
// "John@Gmail.com" was invisible to sign-in and to Google account linking:
// signing in with Google minted a SECOND user row and left their tickets
// stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this
// fixes the rows already in the table.
//
// Idempotent, and deliberately conservative: a row is only lowercased when
// nothing already occupies the lowercase address. A genuine collision means
// two user rows for the same person, each with its own tickets, invoices and
// payments — merging those is a judgement call, not a migration, so they are
// reported for manual review instead.
const lowercaseEmailsSql = `
UPDATE users SET email = LOWER(email)
WHERE email <> LOWER(email)
AND NOT EXISTS (
SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email)
)
`;
if (dbType === 'sqlite') {
await (db as any).run(sql.raw(lowercaseEmailsSql));
} else {
await (db as any).execute(sql.raw(lowercaseEmailsSql));
}
// Whatever still differs from its own lowercase form is exactly the set the
// UPDATE refused to touch, i.e. the collisions.
const collisions = await dbAll<{ id: string; email: string }>(
(db as any)
.select({ id: (users as any).id, email: (users as any).email })
.from(users)
.where(ne((users as any).email, sql`LOWER(${(users as any).email})`))
);
if (collisions.length > 0) {
console.warn(
`WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` +
`lowercase address is already taken. Sign-in and Google linking only ever reach ` +
`the lowercase row, so these need a manual merge:`
);
for (const row of collisions) {
const canonical = row.email.toLowerCase();
const existing = await dbGet<{ id: string }>(
(db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical))
);
console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`);
}
}
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
+1 -52
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
// Type to determine which schema to use
const dbType = process.env.DB_TYPE || 'sqlite';
@@ -20,12 +20,6 @@ export const sqliteUsers = sqliteTable('users', {
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: integer('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -138,32 +132,10 @@ export const sqlitePayments = sqliteTable('payments', {
paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Idempotency records for door check-in actions.
//
// The door screen fires check-ins / walk-in creations optimistically and retries
// on flaky venue wifi, so every action carries a client-generated key. The first
// request stores its response here; replays return that stored response instead
// of creating a second ticket, payment or check-in. `undoState` holds exactly
// what the action changed so the 10-second Undo can reverse it precisely.
export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', {
key: text('key').primaryKey(),
scope: text('scope').notNull(),
result: text('result').notNull(), // JSON response body of the original request
undoState: text('undo_state'), // JSON describing how to reverse the action
undoneAt: text('undone_at'),
createdAt: text('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const sqlitePaymentOptions = sqliteTable('payment_options', {
id: text('id').primaryKey(),
@@ -408,12 +380,6 @@ export const pgUsers = pgTable('users', {
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: pgInteger('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
@@ -526,26 +492,10 @@ export const pgPayments = pgTable('payments', {
paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'),
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: varchar('source', { length: 20 }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: varchar('method', { length: 20 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Idempotency records for door check-in actions (see sqliteIdempotencyKeys).
export const pgIdempotencyKeys = pgTable('idempotency_keys', {
key: varchar('key', { length: 128 }).primaryKey(),
scope: varchar('scope', { length: 64 }).notNull(),
result: pgText('result').notNull(),
undoState: pgText('undo_state'),
undoneAt: timestamp('undone_at'),
createdAt: timestamp('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const pgPaymentOptions = pgTable('payment_options', {
id: uuid('id').primaryKey(),
@@ -772,7 +722,6 @@ export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
+123 -245
View File
@@ -7,12 +7,9 @@ import { logger } from 'hono/logger';
import { swaggerUI } from '@hono/swagger-ui';
import { serveStatic } from '@hono/node-server/serve-static';
import { auth } from './lib/betterAuth.js';
import authExtRoutes from './routes/authExt.js';
import { getClientIp } from './lib/rateLimit.js';
import authRoutes from './routes/auth.js';
import eventsRoutes from './routes/events.js';
import ticketsRoutes from './routes/tickets.js';
import doorRoutes from './routes/door.js';
import usersRoutes from './routes/users.js';
import contactsRoutes from './routes/contacts.js';
import paymentsRoutes from './routes/payments.js';
@@ -60,7 +57,7 @@ app.use(
if (!origin) return frontendUrl;
return allowedOrigins.has(origin) ? origin : null;
},
// Session cookies must be allowed on cross-origin API calls (api.* vhost).
// We use bearer tokens, but keeping credentials=true matches nginx config.
credentials: true,
})
);
@@ -113,15 +110,11 @@ const openApiSpec = {
],
paths: {
// ==================== Auth Endpoints ====================
// Authentication is handled by Better Auth, mounted at /api/auth/*.
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
// below are the subset the frontend uses. See https://better-auth.com/docs
// for the full endpoint reference.
'/api/auth/sign-up/email': {
'/api/auth/register': {
post: {
tags: ['Auth'],
summary: 'Register a new user (Better Auth)',
description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
summary: 'Register a new user',
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
requestBody: {
required: true,
content: {
@@ -131,8 +124,8 @@ const openApiSpec = {
required: ['email', 'password', 'name'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 10 },
name: { type: 'string' },
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
name: { type: 'string', minLength: 2 },
phone: { type: 'string' },
languagePreference: { type: 'string', enum: ['en', 'es'] },
},
@@ -141,17 +134,16 @@ const openApiSpec = {
},
},
responses: {
200: { description: 'User created; session cookie set' },
422: { description: 'Email already registered' },
400: { description: 'Validation error' },
201: { description: 'User created successfully' },
400: { description: 'Email already registered or validation error' },
},
},
},
'/api/auth/sign-in/email': {
'/api/auth/login': {
post: {
tags: ['Auth'],
summary: 'Login with email and password (Better Auth)',
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
summary: 'Login with email and password',
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
requestBody: {
required: true,
content: {
@@ -168,46 +160,42 @@ const openApiSpec = {
},
},
responses: {
200: { description: 'Login successful; session cookie set' },
200: { description: 'Login successful, returns JWT token' },
401: { description: 'Invalid credentials' },
429: { description: 'Too many attempts' },
429: { description: 'Too many login attempts' },
},
},
},
'/api/auth/sign-in/social': {
'/api/auth/google': {
post: {
tags: ['Auth'],
summary: 'Login or register with Google (Better Auth)',
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
summary: 'Login or register with Google',
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['provider'],
required: ['credential'],
properties: {
provider: { type: 'string', enum: ['google'] },
idToken: {
type: 'object',
properties: { token: { type: 'string', description: 'Google ID token' } },
},
credential: { type: 'string', description: 'Google ID token' },
},
},
},
},
},
responses: {
200: { description: 'Login successful; session cookie set' },
401: { description: 'Invalid Google token' },
200: { description: 'Login successful' },
400: { description: 'Invalid Google token' },
},
},
},
'/api/auth/sign-in/magic-link': {
'/api/auth/magic-link/request': {
post: {
tags: ['Auth'],
summary: 'Request magic link login (Better Auth)',
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
summary: 'Request magic link login',
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
requestBody: {
required: true,
content: {
@@ -217,33 +205,46 @@ const openApiSpec = {
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
callbackURL: { type: 'string' },
},
},
},
},
},
responses: { 200: { description: 'Magic link sent (if account exists)' } },
responses: {
200: { description: 'Magic link sent (if account exists)' },
},
},
},
'/api/auth/magic-link/verify': {
get: {
post: {
tags: ['Auth'],
summary: 'Verify magic link token (Better Auth)',
parameters: [
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
],
summary: 'Verify magic link token',
description: 'Verify the magic link token and login user.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Login successful; session cookie set' },
200: { description: 'Login successful' },
400: { description: 'Invalid or expired token' },
},
},
},
'/api/auth/request-password-reset': {
'/api/auth/password-reset/request': {
post: {
tags: ['Auth'],
summary: 'Request password reset (Better Auth)',
description: 'Emails a reset link. Token expires in 30 minutes.',
summary: 'Request password reset',
description: 'Send a password reset link to email. Link expires in 30 minutes.',
requestBody: {
required: true,
content: {
@@ -253,30 +254,31 @@ const openApiSpec = {
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
redirectTo: { type: 'string' },
},
},
},
},
},
responses: { 200: { description: 'Reset link sent (if account exists)' } },
responses: {
200: { description: 'Reset link sent (if account exists)' },
},
},
},
'/api/auth/reset-password': {
'/api/auth/password-reset/confirm': {
post: {
tags: ['Auth'],
summary: 'Reset password with token (Better Auth)',
description: 'Sets a new password and revokes all existing sessions.',
summary: 'Confirm password reset',
description: 'Reset password using the token from email.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['newPassword', 'token'],
required: ['token', 'password'],
properties: {
newPassword: { type: 'string', minLength: 10 },
token: { type: 'string' },
password: { type: 'string', minLength: 10 },
},
},
},
@@ -288,10 +290,62 @@ const openApiSpec = {
},
},
},
'/api/auth/claim-account/request': {
post: {
tags: ['Auth'],
summary: 'Request account claim link',
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
},
},
},
responses: {
200: { description: 'Claim link sent (if unclaimed account exists)' },
},
},
},
'/api/auth/claim-account/confirm': {
post: {
tags: ['Auth'],
summary: 'Confirm account claim',
description: 'Claim an unclaimed account by setting password or linking Google.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
},
},
},
},
},
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Invalid token or missing credentials' },
},
},
},
'/api/auth/change-password': {
post: {
tags: ['Auth'],
summary: 'Change password (Better Auth)',
summary: 'Change password',
description: 'Change password for authenticated user.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
@@ -303,7 +357,6 @@ const openApiSpec = {
properties: {
currentPassword: { type: 'string' },
newPassword: { type: 'string', minLength: 10 },
revokeOtherSessions: { type: 'boolean' },
},
},
},
@@ -316,68 +369,28 @@ const openApiSpec = {
},
},
},
'/api/auth/get-session': {
'/api/auth/me': {
get: {
tags: ['Auth'],
summary: 'Get current session and user (Better Auth)',
summary: 'Get current user',
description: 'Get the currently authenticated user profile.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: '{ session, user } or null when not authenticated' },
200: { description: 'Current user data' },
401: { description: 'Unauthorized' },
},
},
},
'/api/auth/sign-out': {
'/api/auth/logout': {
post: {
tags: ['Auth'],
summary: 'Logout (Better Auth)',
description: 'Revokes the current session and clears the session cookie.',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Logged out' } },
},
},
'/api/auth/list-sessions': {
get: {
tags: ['Auth'],
summary: 'List active sessions (Better Auth)',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Active sessions for the current user' } },
},
},
'/api/auth-ext/claim-account': {
post: {
tags: ['Auth'],
summary: 'Claim a guest-created account',
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 10 } },
},
},
},
},
summary: 'Logout',
description: 'Logout current user (client-side token removal).',
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Already claimed or validation error' },
401: { description: 'No session (claim link required)' },
200: { description: 'Logged out' },
},
},
},
'/api/auth-ext/claim-eligibility': {
get: {
tags: ['Auth'],
summary: 'Check whether an email has an unclaimed account',
parameters: [
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
],
responses: { 200: { description: '{ canClaim: boolean }' } },
},
},
// ==================== User Dashboard Endpoints ====================
'/api/dashboard/summary': {
@@ -768,116 +781,6 @@ const openApiSpec = {
},
},
},
// ==================== Door Check-in Screen ====================
'/api/events/{eventId}/door-attendees': {
get: {
tags: ['Tickets'],
summary: 'Full attendee list for the door check-in screen',
description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event, attendees and check-in stats' },
404: { description: 'Event not found' },
},
},
},
'/api/events/{eventId}/door-checkin': {
post: {
tags: ['Tickets'],
summary: 'Check in, settle payment, or create a walk-in (atomic)',
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: {
ticketId: { type: 'string' },
attendee: {
type: 'object',
required: ['firstName'],
properties: {
firstName: { type: 'string' },
lastName: { type: 'string' },
phone: { type: 'string' },
email: { type: 'string', format: 'email' },
ruc: { type: 'string' },
},
},
payment: {
type: 'object',
required: ['method'],
properties: {
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] },
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' },
},
},
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
idempotencyKey: { type: 'string' },
},
},
},
},
},
responses: {
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
200: { description: 'Replay of an already-processed idempotencyKey' },
400: { description: 'Ticket belongs to a different event' },
404: { description: 'Event or ticket not found' },
},
},
},
'/api/events/{eventId}/door-checkin/undo': {
post: {
tags: ['Tickets'],
summary: 'Reverse one door check-in action',
description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: { idempotencyKey: { type: 'string' } },
},
},
},
},
responses: {
200: { description: 'Action reversed (or already undone)' },
404: { description: 'No action recorded for this key' },
},
},
},
'/api/events/{eventId}/door-summary': {
get: {
tags: ['Payments'],
summary: 'Door cash-up and pre-sale/door revenue split',
description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Door totals by method, door lines, and pre-sale totals' },
404: { description: 'Event not found' },
},
},
},
'/api/tickets/{id}/checkin': {
post: {
tags: ['Tickets'],
@@ -1859,10 +1762,10 @@ const openApiSpec = {
components: {
securitySchemes: {
bearerAuth: {
type: 'apiKey',
in: 'cookie',
name: 'spanglish.session_token',
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT token obtained from login endpoint',
},
},
schemas: {
@@ -1996,32 +1899,7 @@ app.get('/health', (c) => {
});
// API Routes
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
// password reset, Google, session management). CORS above runs first.
//
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
// private header. The inbound value is always discarded — a client cannot
// choose its own rate-limit bucket.
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
const headers = new Headers(c.req.raw.headers);
headers.delete('x-client-ip');
const clientIp = getClientIp(c);
if (clientIp && clientIp !== 'unknown') {
headers.set('x-client-ip', clientIp);
}
return auth.handler(
new Request(c.req.raw, {
headers,
// Node's fetch requires duplex for requests carrying a body stream
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
} as RequestInit)
);
});
app.route('/api/auth-ext', authExtRoutes);
// Door check-in screen endpoints live under /api/events/:eventId/door-*.
// Mounted first so the generic /:id routes below can never shadow them.
app.route('/api/events', doorRoutes);
app.route('/api/auth', authRoutes);
app.route('/api/events', eventsRoutes);
app.route('/api/tickets', ticketsRoutes);
app.route('/api/users', usersRoutes);
+327 -88
View File
@@ -1,78 +1,334 @@
import * as jose from 'jose';
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Context } from 'hono';
import { and, eq } from 'drizzle-orm';
import { auth } from './betterAuth.js';
import { db, dbGet } from '../db/index.js';
import { authAccounts } from '../db/auth-schema.js';
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
import { generateId, getNow, toDbDate } from './utils.js';
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
// sessions validated against the auth_sessions table on every request, so
// revocation (ban/suspend/password reset) applies instantly. This module keeps
// the request-side helpers that the route files use.
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
const rawJwtSecret = process.env.JWT_SECRET;
// Re-exported for routes that hash/validate passwords outside Better Auth
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
export interface AuthUser {
id: string;
email: string;
name: string;
phone: string | null;
role: string;
languagePreference: string | null;
isClaimed: boolean;
rucNumber: string | null;
accountStatus: string;
emailVerified: boolean;
image: string | null;
createdAt: Date | string;
updatedAt: Date | string;
/** ID of the Better Auth session backing this request. */
sessionId: string;
// Never allow the insecure default in production: forgeable tokens = full account takeover.
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
}
if (!rawJwtSecret) {
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
}
/**
* Resolve the authenticated user for a request from its Better Auth session
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
* accounts never get API access even with a live session cookie.
*/
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
try {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return null;
}
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
const JWT_ISSUER = 'spanglish';
const JWT_AUDIENCE = 'spanglish-app';
const user = session.user as any;
export interface JWTPayload {
sub: string;
email: string;
role: string;
tokenVersion?: number;
iat: number;
exp: number;
}
// Suspended (banned) or unclaimed accounts must not retain API access
if (user.banned) {
return null;
}
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Generate secure random token for magic links
export function generateSecureToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Create magic link token
export async function createMagicLinkToken(
userId: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
expiresInMinutes: number = 10
): Promise<string> {
const token = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
await (db as any).insert(magicLinkTokens).values({
id: generateId(),
userId,
token,
type,
expiresAt,
createdAt: now,
});
return token;
}
// Verify and consume magic link token
export async function verifyMagicLinkToken(
token: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow();
const tokenRecord = await dbGet<any>(
(db as any)
.select()
.from(magicLinkTokens)
.where(
and(
eq((magicLinkTokens as any).token, token),
eq((magicLinkTokens as any).type, type)
)
)
);
// Use a single generic error for all invalid states to avoid leaking token state
const genericError = 'Invalid or expired token';
if (!tokenRecord) {
return { valid: false, error: genericError };
}
if (tokenRecord.usedAt) {
return { valid: false, error: genericError };
}
if (new Date(tokenRecord.expiresAt) < new Date()) {
return { valid: false, error: genericError };
}
// Atomically consume the token: only the request that flips used_at from NULL wins.
// This prevents a double-spend race where two concurrent requests both pass the
// read-time "not used" check above.
const result: any = await (db as any)
.update(magicLinkTokens)
.set({ usedAt: now })
.where(and(
eq((magicLinkTokens as any).id, tokenRecord.id),
isNull((magicLinkTokens as any).usedAt)
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected === 0) {
return { valid: false, error: genericError };
}
return { valid: true, userId: tokenRecord.userId };
}
// Create user session
export async function createUserSession(
userId: string,
userAgent?: string,
ipAddress?: string
): Promise<string> {
const sessionToken = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
await (db as any).insert(userSessions).values({
id: generateId(),
userId,
token: sessionToken,
userAgent: userAgent || null,
ipAddress: ipAddress || null,
lastActiveAt: now,
expiresAt,
createdAt: now,
});
return sessionToken;
}
// Get user's active sessions
export async function getUserSessions(userId: string) {
const now = getNow();
return dbAll(
(db as any)
.select()
.from(userSessions)
.where(
and(
eq((userSessions as any).userId, userId),
gt((userSessions as any).expiresAt, now)
)
)
);
}
// Invalidate a specific session
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
const result = await (db as any)
.delete(userSessions)
.where(
and(
eq((userSessions as any).id, sessionId),
eq((userSessions as any).userId, userId)
)
);
return true;
}
// Invalidate all user sessions (logout everywhere)
export async function invalidateAllUserSessions(userId: string): Promise<void> {
await (db as any)
.delete(userSessions)
.where(eq((userSessions as any).userId, userId));
}
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone ?? null,
role: user.role ?? 'user',
languagePreference: user.languagePreference ?? null,
isClaimed: Boolean(user.isClaimed),
rucNumber: user.rucNumber ?? null,
accountStatus: user.accountStatus ?? 'active',
emailVerified: Boolean(user.emailVerified),
image: user.image ?? null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
sessionId: session.session.id,
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('1d')
.sign(JWT_SECRET);
return token;
}
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
export async function bumpTokenVersion(userId: string): Promise<void> {
await (db as any)
.update(users)
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
.where(eq((users as any).id, userId));
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setExpirationTime('30d')
.sign(JWT_SECRET);
return token;
}
export async function verifyToken(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return payload as unknown as JWTPayload;
} catch {
return null;
}
}
export async function getAuthUser(c: Context): Promise<any | null> {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
return null;
}
// Never load the password hash into request context — it is only needed for
// explicit password-verification routes that query it separately.
const user = await dbGet<any>(
(db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
googleId: (users as any).googleId,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
tokenVersion: (users as any).tokenVersion,
createdAt: (users as any).createdAt,
updatedAt: (users as any).updatedAt,
})
.from(users)
.where(eq((users as any).id, payload.sub))
);
if (!user) {
return null;
}
// Reject tokens issued before a logout-everywhere / password change
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
return null;
}
// Suspended/unclaimed accounts must not retain API access via an old JWT
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return user;
}
export function requireAuth(roles?: string[]) {
return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c);
@@ -90,38 +346,21 @@ export function requireAuth(roles?: string[]) {
};
}
/**
* Fetch only the credential password hash (never exposed via getAuthUser).
* Returns null when the user has no password set (Google-only or unclaimed).
*/
export async function isFirstUser(): Promise<boolean> {
const result = await dbAll(
(db as any).select().from(users).limit(1)
);
return !result || result.length === 0;
}
/** Fetch only the password hash column (never expose via getAuthUser). */
export async function getUserPasswordHash(userId: string): Promise<string | null> {
const row = await dbGet<any>(
(db as any)
.select({ password: (authAccounts as any).password })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
.select({ password: (users as any).password })
.from(users)
.where(eq((users as any).id, userId))
);
const hash = row?.password;
return hash && String(hash).length > 0 ? String(hash) : null;
}
/** Whether the user has a linked Google account. */
export async function hasGoogleAccount(userId: string): Promise<boolean> {
const row = await dbGet<any>(
(db as any)
.select({ id: (authAccounts as any).id })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'google')
)
)
);
return !!row;
}
@@ -1,519 +0,0 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { normalizeEmail } from './utils.js';
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values).
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
// Google IS configured here: account linking is the whole point of the tests at
// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely
// when this is unset. No real credentials are involved — the id tokens are
// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below.
const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com';
process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID;
// Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
vi.mock('./email.js', () => ({
sendEmail: vi.fn(async (opts: any) => {
sentEmails.push(opts);
}),
emailService: {},
default: {},
}));
let auth: (typeof import('./betterAuth.js'))['auth'];
let db: any;
let sqlite: any;
function lastEmailTo(email: string) {
const found = [...sentEmails].reverse().find((e) => e.to === email);
expect(found, `expected an email sent to ${email}`).toBeTruthy();
return found!;
}
function extractToken(html: string, param = 'token'): string {
const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
expect(match, `expected a ${param} in the email link`).toBeTruthy();
return decodeURIComponent(match![1]);
}
// ---- Google Identity Services stub -------------------------------------
// verifyGoogleIdToken() checks signature, issuer, audience and max age against
// Google's published JWKS; its only network call is that JWKS fetch. Signing
// with our own key and serving our own JWKS exercises the real verification
// path without touching the network or needing OAuth credentials.
const GOOGLE_KID = 'spanglish-test-key';
let googlePrivateKey: CryptoKey;
async function installGoogleStub() {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
googlePrivateKey = privateKey as CryptoKey;
const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' };
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) {
return new Response(JSON.stringify({ keys: [jwk] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return realFetch(input, init);
}) as typeof fetch;
}
function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) {
return new SignJWT({
email: opts.email,
email_verified: opts.emailVerified ?? true,
name: opts.name ?? 'Google User',
picture: 'https://example.test/avatar.png',
})
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience(GOOGLE_CLIENT_ID)
.setSubject(opts.sub)
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
}
async function signInWithGoogle(
opts: Parameters<typeof googleIdToken>[0],
returnHeaders = false
): Promise<any> {
const token = await googleIdToken(opts);
return auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
...(returnHeaders ? { returnHeaders: true } : {}),
} as any);
}
/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts):
* unclaimed, unverified, and with no auth_accounts row at all. */
function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)`
)
.run(id, email, name, now, now);
return id;
}
function userRow(email: string) {
return sqlite
.prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?')
.get(email);
}
function googleAccountsFor(userId: string) {
return sqlite
.prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'")
.all(userId);
}
function cookieHeaders(setCookie: string | null): Headers {
const sessionPart = (setCookie || '')
.split(/,(?=[^ ;]+=)/)
.map((c) => c.split(';')[0].trim())
.filter((c) => c.includes('session_token'))
.join('; ');
return new Headers({ cookie: sessionPart });
}
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: { ...process.env },
stdio: 'pipe',
});
return (async () => {
({ auth } = await import('./betterAuth.js'));
({ db } = await import('../db/index.js'));
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
await installGoogleStub();
})();
}, 120_000);
describe('Better Auth integration', () => {
it('makes the first registered user an admin, later users regular', async () => {
const first = await auth.api.signUpEmail({
body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
});
expect((first.user as any).id).toBeTruthy();
const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
expect(row.role).toBe('admin');
expect(row.account_status).toBe('active');
await auth.api.signUpEmail({
body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
});
const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
expect(row2.role).toBe('user');
});
it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
const acct = sqlite
.prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
.get('admin@test.py');
expect(acct.password.startsWith('$argon2id$')).toBe(true);
const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
expect(user.password).toBeNull();
});
it('rejects passwords that violate the policy', async () => {
// Too short
await expect(
auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
).rejects.toThrow(/at least 10 characters/);
// Long enough but no character mix (app policy hook)
await expect(
auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
).rejects.toThrow(/uppercase and lowercase/);
// Common password normalized (policy blocklist)
await expect(
auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
).rejects.toThrow(/too common/);
expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
});
it('locks an email after 5 failed sign-ins', async () => {
await auth.api.signUpEmail({
body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
});
for (let i = 0; i < 5; i++) {
await expect(
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
).rejects.toThrow();
}
// Correct password now also refused: locked
await expect(
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
).rejects.toThrow(/Too many login attempts/);
});
it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
await auth.api.signUpEmail({
body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
});
sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
await expect(
auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
).rejects.toThrow(/suspended|banned/i);
});
it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
const bcrypt = (await import('bcryptjs')).default;
const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
const su = await auth.api.signUpEmail({
body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
});
sqlite
.prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
.run(legacyHash, (su.user as any).id);
const si = await auth.api.signInEmail({
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
});
expect(si.user.email).toBe('legacy@test.py');
// Upgrade happens in the after-hook; poll briefly for it
let upgraded = '';
for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
await new Promise((r) => setTimeout(r, 100));
upgraded = sqlite
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
.get((su.user as any).id).password;
}
expect(upgraded.startsWith('$argon2id$')).toBe(true);
// And the upgraded hash still verifies
const again = await auth.api.signInEmail({
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
});
expect(again.user.email).toBe('legacy@test.py');
});
it('magic link signs in existing users but never creates accounts', async () => {
await auth.api.signUpEmail({
body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
});
await auth.api.signInMagicLink({
body: { email: 'magic@test.py', callbackURL: '/dashboard' },
headers: new Headers(),
});
const email = lastEmailTo('magic@test.py');
// Emails link to the frontend page, not the raw API endpoint
expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
const token = extractToken(email.html);
const verified = await auth.api.magicLinkVerify({
query: { token },
headers: new Headers(),
});
expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();
// Unknown email: enumeration-safe success (an email may still go out),
// but verification can never create an account (disableSignUp)
const ghost = await auth.api.signInMagicLink({
body: { email: 'ghost@test.py' },
headers: new Headers(),
});
expect((ghost as any).status).toBe(true);
const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
if (ghostEmail) {
const ghostToken = extractToken(ghostEmail.html);
await expect(
auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
).rejects.toThrow();
}
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
});
it('completes the guest claim path: magic link session + setPassword', async () => {
// Simulate tickets.ts guest creation: user row, no credential account
const guestId = 'guest-claim-user-000001';
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
)
.run(guestId, 'guest@test.py', now, now);
await auth.api.signInMagicLink({
body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
headers: new Headers(),
});
const token = extractToken(lastEmailTo('guest@test.py').html);
const verified = await auth.api.magicLinkVerify({
query: { token },
returnHeaders: true,
headers: new Headers(),
});
const headers = cookieHeaders(verified.headers.get('set-cookie'));
// The session works even while unclaimed (the claim endpoint depends on this)
const session = await auth.api.getSession({ headers });
expect((session?.user as any)?.accountStatus).toBe('unclaimed');
// Set the password (what /api/auth-ext/claim-account does)
await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
const acct = sqlite
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
.get(guestId);
expect(acct.password.startsWith('$argon2id$')).toBe(true);
// The claim email uses the claim template with the frontend link
const claimEmail = lastEmailTo('guest@test.py');
expect(claimEmail.subject).toContain('Claim');
expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
});
it('password reset revokes existing sessions and applies the new password', async () => {
const su = await auth.api.signUpEmail({
body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
});
const userId = (su.user as any).id;
// A live session from sign-in
await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
expect(
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
).toBeGreaterThan(0);
await auth.api.requestPasswordReset({
body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
});
const email = lastEmailTo('reset@test.py');
// Reset URLs are either .../reset-password/{token}?... or ...?token={token}
const html = email.html;
const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);
await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });
// revokeSessionsOnPasswordReset: true
expect(
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
).toBe(0);
await expect(
auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
).rejects.toThrow();
const after = await auth.api.signInEmail({
body: { email: 'reset@test.py', password: 'AfterReset1!x' },
});
expect(after.user.email).toBe('reset@test.py');
});
it('sessions are stored in auth_sessions with 7-day expiry', async () => {
const si = await auth.api.signInEmail({
body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
});
const row = sqlite
.prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
.get((si as any).token);
expect(row).toBeTruthy();
const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
expect(days).toBeGreaterThan(6.5);
expect(days).toBeLessThan(7.5);
});
});
describe('Google sign-in and account linking', () => {
it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => {
const id = insertBookingUser('booking-user-1', 'buyer@test.py');
expect(userRow('buyer@test.py').email_verified).toBe(0);
const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1);
});
it('claims the booking account so the resulting session is actually accepted', async () => {
// getAuthUser() (lib/auth.ts) rejects any session whose user is not
// 'active', so linking alone would leave the user looking logged out.
const id = insertBookingUser('booking-user-2', 'buyer2@test.py');
const { headers, response } = await signInWithGoogle(
{ email: 'buyer2@test.py', sub: 'google-sub-buyer2' },
true
);
expect(response.user.id).toBe(id);
const row = userRow('buyer2@test.py');
expect(row.account_status).toBe('active');
expect(row.is_claimed).toBe(1);
expect(row.email_verified).toBe(1);
const session = await auth.api.getSession({
headers: cookieHeaders(headers.get('set-cookie')),
});
expect(session?.user.id).toBe(id);
expect((session?.user as any).accountStatus).toBe('active');
});
it('never reactivates a suspended account through a Google link', async () => {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at)
VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)`
)
.run('suspended-google', 'suspended-google@test.py', now, now);
await expect(
signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' })
).rejects.toThrow();
expect(userRow('suspended-google@test.py').account_status).toBe('suspended');
});
it('links Google onto an email/password account created after the Better Auth migration', async () => {
// Better Auth writes email_verified = 0 on sign-up (requireEmailVerification
// is off), which used to be enough to block linking on its own.
await auth.api.signUpEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' },
});
expect(userRow('pwuser@test.py').email_verified).toBe(0);
const id = userRow('pwuser@test.py').id;
const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
// The credential account survives: they can still sign in with a password.
const after = await auth.api.signInEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' },
});
expect(after.user.id).toBe(id);
});
it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => {
const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
const row = userRow('fresh@test.py');
expect(row.id).toBe(first.user.id);
expect(row.email_verified).toBe(1);
expect(row.account_status).toBe('active');
const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
expect(second.user.id).toBe(first.user.id);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1);
expect(googleAccountsFor(first.user.id)).toHaveLength(1);
});
it('rejects an id token minted for a different client id', async () => {
const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' })
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience('some-other-app.apps.googleusercontent.com')
.setSubject('google-sub-forged')
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
await expect(
auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
} as any)
).rejects.toThrow();
expect(userRow('forged@test.py')).toBeUndefined();
});
});
describe('users.email normalization', () => {
it('normalizes an address to its canonical stored form', () => {
expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com');
});
it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => {
const now = new Date().toISOString();
const insert = (id: string, email: string, status = 'unclaimed') =>
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)`
)
.run(id, email, status, now, now);
insert('legacy-mixed', 'John@Example.com');
// A pair that genuinely collides: the migration must leave both alone.
insert('legacy-dup-lower', 'dup@example.com');
insert('legacy-dup-mixed', 'Dup@Example.com');
// The backfill lives in migrate.ts and is idempotent, so just re-run it.
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
expect(userRow('john@example.com').id).toBe('legacy-mixed');
expect(userRow('John@Example.com')).toBeUndefined();
expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed');
expect(userRow('dup@example.com').id).toBe('legacy-dup-lower');
// ...and the lowercased row is now reachable by Google sign-in.
const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' });
expect(res.user.id).toBe('legacy-mixed');
}, 120_000);
});
-420
View File
@@ -1,420 +0,0 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { magicLink, admin } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { eq, and } from 'drizzle-orm';
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
import {
authUsers,
authSessions,
authAccounts,
authVerifications,
authRateLimits,
} from '../db/auth-schema.js';
import { generateId } from './utils.js';
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
import { sendEmail } from './email.js';
import { getLoginLockout } from './stores/loginLockout.js';
const isProduction = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
// and needs a host-only cookie.
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
const rawSecret = process.env.BETTER_AUTH_SECRET;
// Never allow a weak/default secret in production: the secret signs session
// cookies, so a guessable value means forgeable sessions = account takeover.
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
throw new Error(
'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
);
}
if (!rawSecret) {
console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
}
// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
// index.ts. Requests whose Origin is not listed here are rejected by Better
// Auth's CSRF origin check.
function computeTrustedOrigins(): string[] {
const origins = new Set<string>([frontendUrl]);
try {
const url = new URL(frontendUrl);
const alias = url.hostname.startsWith('www.')
? url.hostname.slice(4)
: `www.${url.hostname}`;
origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
} catch {
/* keep frontendUrl as-is */
}
if (process.env.API_URL) origins.add(process.env.API_URL);
if (!isProduction) {
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
// port forwarding), so the browser's Origin is http://localhost:<random> and every
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
origins.add('http://localhost:*');
origins.add('http://127.0.0.1:*');
}
return [...origins];
}
// Paths whose request body carries a new password that must satisfy the policy
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
const PASSWORD_SETTING_PATHS = new Set([
'/sign-up/email',
'/reset-password',
'/change-password',
'/set-password',
]);
async function getCredentialAccount(userId: string): Promise<any | null> {
return dbGet<any>(
(db as any)
.select()
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
);
}
export const auth = betterAuth({
appName: 'Spanglish',
baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
basePath: '/api/auth',
secret: rawSecret || DEFAULT_DEV_SECRET,
trustedOrigins: computeTrustedOrigins(),
telemetry: { enabled: false },
database: drizzleAdapter(db as any, {
provider: isPostgres() ? 'pg' : 'sqlite',
schema: {
user: authUsers,
session: authSessions,
account: authAccounts,
verification: authVerifications,
rateLimit: authRateLimits,
},
// better-sqlite3 cannot run Drizzle's async transactions; operations run
// sequentially instead (also the adapter default).
transaction: false,
}),
advanced: {
cookiePrefix: 'spanglish',
useSecureCookies: isProduction,
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
// stay valid.
...(cookieDomain
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
: {}),
ipAddress: {
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
// anchors trust in the TCP peer address (only our own proxies may speak
// for the client via X-Real-IP / X-Forwarded-For). Never read the raw
// forwarded headers here: without the socket they are spoofable, and
// Better Auth would fall back to one shared rate-limit bucket.
ipAddressHeaders: ['x-client-ip'],
},
database: {
// Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
// so Better Auth rows are indistinguishable from legacy rows.
generateId: () => generateId(),
},
},
user: {
additionalFields: {
// `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
phone: { type: 'string', required: false, input: true },
languagePreference: { type: 'string', required: false, input: true },
rucNumber: { type: 'string', required: false, input: false },
isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
updateAge: 60 * 60 * 24, // refresh expiry at most once a day
freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
// Disabled deliberately: every request validates against the session table,
// so ban/suspend/password-reset revocations apply instantly — and the Go
// photo-api (which reads the same table) can never disagree with us.
cookieCache: { enabled: false },
storeSessionInDatabase: true,
},
emailAndPassword: {
enabled: true,
minPasswordLength: 10,
maxPasswordLength: 128,
autoSignIn: true,
requireEmailVerification: false,
revokeSessionsOnPasswordReset: true,
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
sendResetPassword: async ({ user, url }) => {
try {
await sendEmail({
to: user.email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${url}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
},
password: {
// Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
// into auth_accounts) still verify and are upgraded on login below.
hash: (password) => hashPassword(password),
verify: ({ hash, password }) => verifyPassword(password, hash),
},
},
...(process.env.GOOGLE_CLIENT_ID
? {
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
// Not required for ID-token (Google Identity Services) sign-in,
// only for the redirect OAuth flow.
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
},
},
}
: {}),
account: {
accountLinking: {
enabled: true,
// Google verifies email ownership, so linking by email is safe — this
// matches the legacy /api/auth/google auto-link behavior.
trustedProviders: ['google'],
// `trustedProviders` alone is NOT enough: better-auth ORs a second,
// independent gate — `requireLocalEmailVerified` (default true) — which
// refuses the link whenever the LOCAL users.email_verified is false.
// That is the state of every guest-booking user (routes/tickets.ts,
// routes/door.ts insert email_verified = false) and of every
// email/password signup made after the Better Auth migration, so Google
// sign-in failed for them with "account not linked".
//
// The local flag adds nothing here: the Google ID token is signature-
// verified against Google's JWKS with issuer/audience/max-age checks and
// carries its own `email_verified`, so Google — not our column — is what
// proves ownership of the address.
//
// NOTE: upstream marks this option deprecated ("the gate will become
// unconditional"). better-auth is pinned exactly at 1.6.25 in both
// workspaces, and betterAuth.integration.test.ts covers this path, so an
// upgrade that drops the option fails CI rather than silently locking
// ticket buyers out again.
requireLocalEmailVerified: false,
},
},
rateLimit: {
// Explicitly enabled so dev behaves like production (off in dev by default).
enabled: true,
window: 60,
max: 100,
// DB-backed rather than Redis: our Redis layer is fail-open by design,
// which is the wrong default for auth rate limiting. The per-email login
// lockout below is already Redis-shared across replicas.
storage: 'database',
modelName: 'rateLimit',
customRules: {
'/sign-in/email': { window: 900, max: 10 },
'/sign-up/email': { window: 900, max: 10 },
'/sign-in/magic-link': { window: 900, max: 5 },
'/magic-link/verify': { window: 900, max: 30 },
'/request-password-reset': { window: 900, max: 5 },
'/reset-password': { window: 900, max: 10 },
'/sign-in/social': { window: 900, max: 20 },
'/change-password': { window: 900, max: 10 },
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
// First user to register becomes admin (replaces isFirstUser())
const existing = await dbAll<any>((db as any).select().from(authUsers).limit(1));
if (!existing || existing.length === 0) {
return { data: { ...user, role: 'admin' } };
}
},
},
},
account: {
create: {
// Fires for both branches of the OAuth path: createOAuthUser (new user)
// and linkAccount (existing user), since both go through the adapter's
// createWithHooks(..., 'account').
after: async (account) => {
if (account.providerId !== 'google') return;
// Attaching a Google account proves ownership of the address, so a
// row created during guest booking is now a real, claimed account.
// Without this, getAuthUser() (lib/auth.ts) rejects the brand-new
// session because accountStatus is still 'unclaimed' — the user gets
// a cookie and still looks logged out. Mirrors the tail of the
// magic-link claim flow in routes/authExt.ts.
//
// Scoped to 'unclaimed' in the WHERE clause so a suspended account is
// never silently reactivated by linking Google to it.
try {
await (db as any)
.update(authUsers)
// `emailVerified` is deliberately left alone: better-auth's link
// branch sets it right after this hook, but only when Google's
// id_token actually asserted email_verified.
.set({
isClaimed: true,
accountStatus: 'active',
updatedAt: new Date(),
})
.where(
and(
eq((authUsers as any).id, account.userId),
eq((authUsers as any).accountStatus, 'unclaimed')
)
);
} catch (err: any) {
console.error('[auth] Failed to claim account on Google link:', err?.message || err);
}
},
},
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Enforce the full password policy (character classes + blocklist) on
// every password-setting path, for both client and server-side calls.
if (PASSWORD_SETTING_PATHS.has(ctx.path)) {
const password = ctx.body?.password ?? ctx.body?.newPassword;
if (typeof password === 'string') {
const result = validatePassword(password);
if (!result.valid) {
throw new APIError('BAD_REQUEST', { message: result.error });
}
}
}
// Per-email lockout: 5 failures / 15 min, Redis-shared when configured.
// Kept as defense-in-depth on top of Better Auth's per-IP rate limits.
if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') {
const lockout = await getLoginLockout().isLocked(ctx.body.email);
if (lockout.locked) {
throw new APIError('TOO_MANY_REQUESTS', {
message: 'Too many login attempts. Please try again later.',
});
}
}
}),
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return;
const email = ctx.body.email as string;
if (ctx.context.returned instanceof APIError) {
// Failed sign-in attempt counts toward the per-email lockout
await getLoginLockout().recordFailure(email);
return;
}
await getLoginLockout().clear(email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have
// the verified plaintext. Best-effort: never block the login.
try {
const password = ctx.body?.password;
const userId = (ctx.context.newSession?.user as any)?.id;
if (typeof password === 'string' && userId) {
const account = await getCredentialAccount(userId);
if (account?.password && !String(account.password).startsWith('$argon2')) {
const upgraded = await hashPassword(password);
await (db as any)
.update(authAccounts)
.set({ password: upgraded, updatedAt: new Date() })
.where(eq((authAccounts as any).id, account.id));
}
}
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}),
},
plugins: [
magicLink({
expiresIn: 60 * 10, // 10 minutes, matches the legacy flow
// Magic links never create accounts (parity with the legacy behavior;
// account creation is register / Google / guest booking only).
disableSignUp: true,
// Hashed at rest: a leaked verification table cannot be replayed.
storeToken: 'hashed',
sendMagicLink: async ({ email, url, token }) => {
// Email a frontend URL (not the raw API verify URL) so the login
// completes on the site, preserving the legacy UX. The page calls
// authClient.magicLink.verify with the token.
let callbackURL = '/';
try {
callbackURL = new URL(url).searchParams.get('callbackURL') || '/';
} catch {
/* default */
}
const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`;
const isClaim = callbackURL.startsWith('/auth/claim-account');
try {
await sendEmail({
to: email,
subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link',
html: isClaim
? `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${link}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`
: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${link}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
},
}),
admin({
defaultRole: 'user',
adminRoles: ['admin'],
bannedUserMessage: 'Account is suspended. Please contact support.',
}),
],
});
export type Auth = typeof auth;
-51
View File
@@ -1,51 +0,0 @@
// Door payment tenders.
//
// The door check-in screen offers four one-tap tenders. Each maps onto an
// existing payments.provider so the rest of the app (capacity, sweeps, admin
// payment lists, receipts) keeps working unchanged, while payments.method
// records which tender was actually used for the end-of-night cash-up.
//
// Bitcoin currently maps to the 'lightning' provider but records the payment as
// already made — the same trust model as cash, no invoice generated. When a real
// Lightning flow lands it slots in here: the tender keeps its name and provider,
// only the settlement path in routes/door.ts changes.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
interface DoorTender {
/** Existing payments.provider this tender is stored as. */
provider: 'cash' | 'lightning' | 'bank_transfer';
/** Human label used in payment references and toasts. */
label: string;
/** Comp tenders carry no revenue and always record a zero amount. */
isComp: boolean;
}
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
cash: { provider: 'cash', label: 'cash', isComp: false },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
guest: { provider: 'cash', label: 'guest', isComp: true },
};
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
}
/** Ticket paymentStatus a tender settles to: comps are 'comp', everything else 'paid'. */
export function paymentStatusForMethod(method: DoorPaymentMethod): 'paid' | 'comp' {
return DOOR_TENDERS[method].isComp ? 'comp' : 'paid';
}
/** Amount actually recorded: comps are always zero regardless of what was requested. */
export function amountForMethod(method: DoorPaymentMethod, requested: number): number {
return DOOR_TENDERS[method].isComp ? 0 : Math.max(0, requested);
}
export function doorReference(method: DoorPaymentMethod): string {
return DOOR_TENDERS[method].isComp
? 'Door — guest (comp)'
: `Door — paid by ${DOOR_TENDERS[method].label}`;
}
-62
View File
@@ -1,62 +0,0 @@
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
+199 -356
View File
@@ -1,8 +1,6 @@
// PDF Ticket Generation Service
import PDFDocument from 'pdfkit';
import QRCode from 'qrcode';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
interface TicketData {
id: string;
@@ -17,390 +15,235 @@ interface TicketData {
locationUrl?: string;
};
timezone?: string;
/** 'en' | 'es' - drives the labels and the date/time format on the ticket */
locale?: string;
/** Optional perk line shown under the ticket holder (falls back to the terms line) */
note?: string;
}
// ==================== Brand ====================
const COLORS = {
navy: '#002F44',
orange: '#F5821F',
cream: '#FDF8F0',
card: '#FFFFFF',
cardBorder: '#EFE6D8',
divider: '#E7DFD1',
label: '#9AA3AC',
muted: '#6B7580',
footerMuted: '#7FA3B5',
};
const PAGE_W = 595.28;
const PAGE_H = 841.89;
const MARGIN = 48;
const CONTENT_W = PAGE_W - MARGIN * 2;
const ACCENT_H = 10;
const FOOTER_H = 48;
const LOGO_RATIO = 1158 / 324;
const STRINGS = {
en: {
scan: 'SCAN AT THE ENTRANCE',
venue: 'VENUE',
holder: 'TICKET HOLDER',
terms: 'This ticket is non-transferable. One scan per entry.',
},
es: {
scan: 'ESCANEÁ AL INGRESAR',
venue: 'LUGAR',
holder: 'TITULAR',
terms: 'Esta entrada es personal e intransferible. Un escaneo por ingreso.',
},
} as const;
function strings(locale?: string) {
return locale === 'es' ? STRINGS.es : STRINGS.en;
}
/**
* Locate the logo. `../../assets` resolves to backend/assets from both
* src/lib (tsx) and dist/lib (compiled), with the frontend copy as a fallback.
*/
function loadLogo(): Buffer | null {
const candidates = [
new URL('../../assets/logo-spanglish.png', import.meta.url),
new URL('../../../frontend/public/images/logo-spanglish.png', import.meta.url),
].map((u) => fileURLToPath(u));
for (const path of candidates) {
if (existsSync(path)) return readFileSync(path);
}
return null;
}
let logoCache: Buffer | null | undefined;
function getLogo(): Buffer | null {
if (logoCache === undefined) logoCache = loadLogo();
return logoCache;
}
/**
* Generate a QR code as a PNG buffer
* Generate a QR code as a data URL
*/
async function generateQRCode(data: string): Promise<Buffer> {
return QRCode.toBuffer(data, {
type: 'png',
width: 600,
margin: 1,
width: 200,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#FFFFFF' },
});
}
/**
* Short date + time as shown in the ticket header:
* en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30"
* Format date for display using site timezone
*/
function formatWhen(
startStr: string,
endStr: string | undefined,
timezone: string,
locale: string
): string {
const isEs = locale === 'es';
const start = new Date(startStr);
const tag = isEs ? 'es-ES' : 'en-US';
const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone });
const month = start
.toLocaleDateString(tag, { month: 'short', timeZone: timezone })
.replace(/\.$/, '')
.toUpperCase();
const time = (d: Date) =>
d
.toLocaleTimeString(tag, {
hour: isEs ? '2-digit' : 'numeric',
minute: '2-digit',
hour12: !isEs,
timeZone: timezone,
})
.toUpperCase();
const date = isEs ? `${day} ${month}` : `${month} ${day}`;
const end = endStr ? new Date(endStr) : null;
const when = end ? `${time(start)} ${time(end)}` : time(start);
return `${date} · ${when}`;
}
/**
* Events store the venue as a single string; the part before the first comma
* reads as the venue name and the remainder as its address.
*/
function splitLocation(location: string): { name: string; address?: string } {
const idx = location.indexOf(',');
if (idx === -1) return { name: location.trim() };
return {
name: location.slice(0, idx).trim(),
address: location.slice(idx + 1).trim() || undefined,
};
}
// ==================== Drawing helpers ====================
function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
doc
.font('Helvetica-Bold')
.fontSize(8)
.fillColor(COLORS.label)
.text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 });
}
function drawDivider(doc: PDFKit.PDFDocument, y: number) {
doc
.moveTo(MARGIN, y)
.lineTo(PAGE_W - MARGIN, y)
.lineWidth(1)
.strokeColor(COLORS.divider)
.stroke();
}
/** Centered text with letter spacing: pdfkit also spaces the last glyph, so nudge it back. */
function drawSpacedCentered(
doc: PDFKit.PDFDocument,
text: string,
x: number,
y: number,
width: number,
spacing: number
) {
doc.text(text, x - spacing / 2, y, { width, align: 'center', characterSpacing: spacing });
}
interface DetailBlock {
label: string;
value: string;
sub?: string;
}
/**
* Draw (or, with `measureOnly`, just measure) the venue / ticket holder / note
* block. Returns its total height so the caller can anchor it above the footer.
*/
function renderDetails(
doc: PDFKit.PDFDocument,
blocks: DetailBlock[],
note: string,
yStart: number,
measureOnly: boolean
): number {
let y = yStart;
blocks.forEach((block, i) => {
if (i > 0) {
y += 14;
if (!measureOnly) drawDivider(doc, y);
y += 18;
}
if (!measureOnly) drawLabel(doc, block.label, y);
y += 15;
doc.font('Helvetica-Bold').fontSize(13);
if (!measureOnly) doc.fillColor(COLORS.navy).text(block.value, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(block.value, { width: CONTENT_W }) + 3;
if (block.sub) {
doc.font('Helvetica').fontSize(10.5);
if (!measureOnly) doc.fillColor(COLORS.muted).text(block.sub, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(block.sub, { width: CONTENT_W }) + 3;
}
});
y += 16;
doc.font('Helvetica').fontSize(10.5);
if (!measureOnly) doc.fillColor(COLORS.muted).text(note, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(note, { width: CONTENT_W });
return y - yStart;
}
/**
* Render one full-page ticket. Assumes the page is already added.
*/
function renderTicketPage(
doc: PDFKit.PDFDocument,
ticket: TicketData,
qrBuffer: Buffer,
siteDomain: string,
index = 0,
total = 1
) {
const locale = ticket.locale === 'es' ? 'es' : 'en';
const t = strings(locale);
const tz = ticket.timezone || 'America/Asuncion';
const footerY = PAGE_H - FOOTER_H;
// ==================== Background ====================
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
// ==================== Logo ====================
const logo = getLogo();
let headerY = MARGIN + 6;
if (logo) {
const logoW = 158;
doc.image(logo, MARGIN, headerY, { width: logoW });
headerY += logoW / LOGO_RATIO;
} else {
doc.font('Helvetica-Bold').fontSize(21).fillColor(COLORS.navy).text('spanglish social', MARGIN, headerY);
headerY += 26;
}
// ==================== Title + date ====================
const titleY = headerY + 30;
const when = formatWhen(ticket.event.startDatetime, ticket.event.endDatetime, tz, locale);
doc.font('Helvetica-Bold').fontSize(11.5);
const whenW = Math.min(doc.widthOfString(when) + 2, CONTENT_W * 0.5);
const titleW = CONTENT_W - whenW - 20;
doc.font('Helvetica-Bold').fontSize(26);
if (doc.widthOfString(ticket.event.title) > titleW) doc.fontSize(20);
doc.fillColor(COLORS.navy).text(ticket.event.title, MARGIN, titleY, { width: titleW });
const titleBottom = doc.y;
doc
.font('Helvetica-Bold')
.fontSize(11.5)
.fillColor(COLORS.orange)
.text(when, PAGE_W - MARGIN - whenW, titleY + 9, { width: whenW, align: 'right' });
// ==================== Layout: card fills what the detail block leaves ====================
const venue = splitLocation(ticket.event.location);
const note = ticket.note || t.terms;
const blocks: DetailBlock[] = [
{ label: t.venue, value: venue.name, sub: venue.address },
{ label: t.holder, value: ticket.attendeeName, sub: ticket.attendeeEmail },
];
const detailsH = renderDetails(doc, blocks, note, 0, true);
const detailsY = footerY - 46 - detailsH;
const cardY = Math.max(titleBottom, titleY + 36) + 24;
const cardX = MARGIN;
const cardW = CONTENT_W;
const cardH = Math.max(300, Math.min(detailsY - 32 - cardY, 430));
doc
.roundedRect(cardX, cardY, cardW, cardH, 14)
.lineWidth(1)
.fillAndStroke(COLORS.card, COLORS.cardBorder);
// ==================== QR card contents ====================
const labelH = 12;
const codeH = 24;
const qrSize = Math.min(236, cardH - (labelH + 20 + 22 + codeH + 44));
const stackH = labelH + 20 + qrSize + 22 + codeH;
let inner = cardY + (cardH - stackH) / 2;
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(COLORS.label);
drawSpacedCentered(doc, t.scan, cardX, inner, cardW, 2);
if (total > 1) {
doc
.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(COLORS.label)
.text(`${index + 1} / ${total}`, cardX, inner, { width: cardW - 22, align: 'right', characterSpacing: 1 });
}
inner += labelH + 20;
doc.image(qrBuffer, (PAGE_W - qrSize) / 2, inner, { width: qrSize, height: qrSize });
inner += qrSize + 22;
const code = ticket.qrCode || ticket.id.slice(0, 8).toUpperCase();
doc.font('Courier-Bold').fontSize(19).fillColor(COLORS.navy);
drawSpacedCentered(doc, code, cardX, inner, cardW, 3);
// ==================== Venue / ticket holder / note ====================
renderDetails(doc, blocks, note, detailsY, false);
// ==================== Footer ====================
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
doc
.font('Courier')
.fontSize(7.5)
.fillColor(COLORS.footerMuted)
.text(ticket.id, MARGIN, footerY + FOOTER_H / 2 - 4, { width: CONTENT_W * 0.6, lineBreak: false });
doc
.font('Helvetica')
.fontSize(10)
.fillColor('#FFFFFF')
.text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
}
function createDoc(): PDFKit.PDFDocument {
return new PDFDocument({ size: 'A4', margin: 0 });
}
function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: timezone,
});
}
function siteUrl(): { base: string; domain: string } {
const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
let domain = base;
try {
domain = new URL(base).host.replace(/^www\./, '');
} catch {
domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '');
}
return { base, domain };
/**
* Format time for display using site timezone
*/
function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZone: timezone,
});
}
/**
* Generate a PDF ticket for a single ticket
*/
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
return generateCombinedTicketsPDF([ticket]);
return new Promise(async (resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
// Generate QR code with ticket URL
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
// Center the QR code
const qrSize = 180;
const pageWidth = 595; // A4 width in points
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
doc.end();
} catch (error) {
reject(error);
}
});
}
/**
* Generate a combined PDF with multiple tickets (one page each)
* Generate a combined PDF with multiple tickets
*/
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
const doc = createDoc();
const done = collect(doc);
const { base, domain } = siteUrl();
return new Promise(async (resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
});
try {
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (i > 0) doc.addPage();
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`);
renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length);
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (i > 0) {
doc.addPage();
}
// Generate QR code
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
const qrSize = 180;
const pageWidth = 595;
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// Ticket number for multi-ticket bookings
if (tickets.length > 1) {
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
}
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
}
doc.end();
} catch (error) {
reject(error);
}
doc.end();
} catch (error) {
doc.end();
throw error;
}
return done;
});
}
export default {
-87
View File
@@ -1,87 +0,0 @@
import { describe, it, expect } from 'vitest';
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
// Minimal Hono-Context stand-in: headers + the node-server env with the
// socket peer address.
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
const headers = new Map(
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
);
return {
req: { header: (name: string) => headers.get(name.toLowerCase()) },
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
} as any;
}
describe('isTrustedProxyIp', () => {
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('::1')).toBe(true);
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
});
it('does not trust public addresses or near-miss ranges', () => {
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
expect(isTrustedProxyIp('')).toBe(false);
});
});
describe('getClientIp', () => {
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
// spoofed prefix, then the real client appended by nginx, then the Next
// proxy hop — the rightmost untrusted entry wins
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('ignores forwarded headers entirely when the peer is untrusted', () => {
// A client hitting the API directly cannot pick its own bucket
const c = fakeContext({
peer: '198.51.100.4',
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
});
expect(getClientIp(c)).toBe('198.51.100.4');
});
it('falls back to the socket address for local traffic with no headers', () => {
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
});
it('falls back to the socket address when every forwarded hop is internal', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '127.0.0.1' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('rejects junk header values instead of using them as bucket keys', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('returns "unknown" without a socket address or trusted headers', () => {
expect(getClientIp(fakeContext({}))).toBe('unknown');
});
});
+4 -61
View File
@@ -10,68 +10,11 @@ import { getRateLimiter } from './stores/rateLimiter.js';
* (horizontal scaling). See lib/stores/rateLimiter.ts.
*/
// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For:
// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges
// (the docker-compose scale deployment, where nginx is another container).
// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20.").
const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1'];
function trustedProxyPrefixes(): string[] {
const extra = (process.env.TRUSTED_PROXIES || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra];
}
/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */
function normalizeIp(ip: string | undefined | null): string {
const trimmed = (ip || '').trim();
return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed;
}
export function isTrustedProxyIp(ip: string): boolean {
const normalized = normalizeIp(ip);
if (!normalized) return false;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12
return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix));
}
// Rough shape check so a junk header value can't become a rate-limit key.
function looksLikeIp(value: string): boolean {
return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value);
}
/**
* Spoof-resistant client IP resolution.
*
* The TCP peer address (via @hono/node-server's env.incoming) anchors the
* trust decision: forwarded headers are only honoured when the direct peer is
* one of our own proxies. X-Real-IP is preferred because nginx overwrites it
* at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is
* walked from the right past our proxy hops — the leftmost entries are
* client-controlled and never trusted on their own.
*/
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
export function getClientIp(c: Context): string {
const socketAddr = normalizeIp((c.env as any)?.incoming?.socket?.remoteAddress);
if (socketAddr && isTrustedProxyIp(socketAddr)) {
const realIp = normalizeIp(c.req.header('x-real-ip'));
if (realIp && looksLikeIp(realIp)) return realIp;
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) {
const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean);
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxyIp(chain[i])) {
return looksLikeIp(chain[i]) ? chain[i] : socketAddr;
}
}
// Every hop is one of ours: a genuinely local/internal client.
}
}
return socketAddr || 'unknown';
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
return c.req.header('x-real-ip') || 'unknown';
}
/**
-42
View File
@@ -1,42 +0,0 @@
// Engine-neutral transactional writes.
//
// better-sqlite3 transactions take a *synchronous* callback (awaiting inside one
// silently breaks atomicity), while node-postgres takes an async one. Rather than
// fork every multi-write route into two near-identical branches, callers build a
// plain list of operations and hand it here: the business logic stays in one
// place and only the six lines below know which driver is underneath.
import { db, isSqlite } from '../db/index.js';
export type TxOp =
| { kind: 'insert'; table: any; values: any }
| { kind: 'update'; table: any; values: any; where: any }
| { kind: 'delete'; table: any; where: any };
export const insertOp = (table: any, values: any): TxOp => ({ kind: 'insert', table, values });
export const updateOp = (table: any, values: any, where: any): TxOp => ({ kind: 'update', table, values, where });
export const deleteOp = (table: any, where: any): TxOp => ({ kind: 'delete', table, where });
/** Apply every op inside a single transaction; any throw rolls back all of them. */
export async function runOps(ops: TxOp[]): Promise<void> {
if (ops.length === 0) return;
if (isSqlite()) {
(db as any).transaction((tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') tx.insert(op.table).values(op.values).run();
else if (op.kind === 'update') tx.update(op.table).set(op.values).where(op.where).run();
else tx.delete(op.table).where(op.where).run();
}
});
return;
}
await (db as any).transaction(async (tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') await tx.insert(op.table).values(op.values);
else if (op.kind === 'update') await tx.update(op.table).set(op.values).where(op.where);
else await tx.delete(op.table).where(op.where);
}
});
}
-13
View File
@@ -22,19 +22,6 @@ export function generateTicketCode(): string {
return `TKT-${nanoid(8).toUpperCase()}`;
}
/**
* Canonical form for `users.email`.
*
* Better Auth lowercases the address on every lookup and write it performs,
* but the `users.email` unique index is case-sensitive on both dialects. Any
* row written outside Better Auth (guest bookings, door sales, admin-added
* tickets) must therefore be normalized the same way, or the row becomes
* invisible to sign-in / Google linking and a duplicate person gets created.
*/
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* Get current timestamp in the format appropriate for the database type.
* - SQLite: returns ISO string
+684
View File
@@ -0,0 +1,684 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm';
import {
hashPassword,
verifyPassword,
createToken,
createRefreshToken,
isFirstUser,
getAuthUser,
validatePassword,
createMagicLinkToken,
verifyMagicLinkToken,
invalidateAllUserSessions,
bumpTokenVersion,
requireAuth,
getUserPasswordHash,
} from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import { getLoginLockout } from '../lib/stores/loginLockout.js';
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
// email-dispatching flows) to curb credential stuffing and email flooding.
const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' });
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const auth = new Hono();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
name: z.string().min(2),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
const magicLinkRequestSchema = z.object({
email: z.string().email(),
});
const magicLinkVerifySchema = z.object({
token: z.string(),
});
const passwordResetRequestSchema = z.object({
email: z.string().email(),
});
const passwordResetSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const claimAccountSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
});
const googleAuthSchema = z.object({
credential: z.string(), // Google ID token
});
// Register
auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => {
const data = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(data.password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Check if email exists
const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (existing) {
// If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
return c.json({
error: 'Email already registered',
canClaim: true,
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
}, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Check if first user (becomes admin)
const firstUser = await isFirstUser();
const hashedPassword = await hashPassword(data.password);
const now = getNow();
const id = generateId();
const newUser = {
id,
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone || null,
role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null,
isClaimed: toDbBool(true),
googleId: null,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
const token = await createToken(id, data.email, newUser.role, 0);
const refreshToken = await createRefreshToken(id);
return c.json({
user: {
id,
email: data.email,
name: data.name,
role: newUser.role,
isClaimed: true,
},
token,
refreshToken,
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
}, 201);
});
// Login with email/password
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Per-email lockout (shared across instances when Redis is configured).
const lockout = await getLoginLockout().isLocked(data.email);
if (lockout.locked) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: lockout.retryAfter
}, 429);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) {
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Check if account is suspended
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
// Check if user has a password set
if (!user.password) {
return c.json({
error: 'No password set for this account',
needsClaim: !user.isClaimed,
message: user.isClaimed
? 'Please use Google login or request a password reset.'
: 'Please claim your account first.'
}, 400);
}
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
await getLoginLockout().clear(data.email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
// plaintext and have verified it. Best-effort: a failure here must not block
// the login.
if (!String(user.password).startsWith('$argon2')) {
try {
const upgradedHash = await hashPassword(data.password);
await (db as any)
.update(users)
.set({ password: upgradedHash })
.where(eq((users as any).id, user.id));
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token,
refreshToken,
});
});
// Request magic link login
auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
// Create magic link token (expires in 10 minutes)
const token = await createMagicLinkToken(user.id, 'login', 10);
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Your Spanglish Login Link',
html: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${magicLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
});
// Verify magic link and login
auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => {
const { token } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'login');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400);
}
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
});
// Request password reset
auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
// Create reset token (expires in 30 minutes)
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${resetLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
});
// Reset password
auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => {
const { token, password } = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const verification = await verifyMagicLinkToken(token, 'reset_password');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, verification.userId));
// Invalidate all existing sessions/JWTs for security
await invalidateAllUserSessions(verification.userId!);
await bumpTokenVersion(verification.userId!);
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
});
// Claim unclaimed account
auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
}
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
return c.json({ error: 'Account is already claimed' }, 400);
}
// Create claim token (expires in 1 hour)
const token = await createMagicLinkToken(user.id, 'claim_account', 60);
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Claim Your Spanglish Account',
html: `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${claimLink}</p>
<p>This link expires in 1 hour.</p>
`,
});
} catch (error) {
console.error('Failed to send claim account email:', error);
}
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
});
// Complete account claim
auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
const { token, password } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'claim_account');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const now = getNow();
// Only set a password here. Linking a Google account requires a verified Google
// ID token via /google; we never trust a client-supplied googleId.
const updates: Record<string, any> = {
isClaimed: toDbBool(true),
accountStatus: 'active',
password: await hashPassword(password),
updatedAt: now,
};
await (db as any)
.update(users)
.set(updates)
.where(eq((users as any).id, verification.userId));
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
message: 'Account claimed successfully!',
});
});
// Google OAuth login/register
auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
const { credential } = c.req.valid('json');
try {
// Verify the Google ID token. Google's tokeninfo endpoint validates the
// signature and expiry server-side; we additionally enforce the audience so a
// token minted for a different OAuth client cannot be replayed against us.
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
if (!response.ok) {
return c.json({ error: 'Invalid Google token' }, 400);
}
const googleData = await response.json() as {
sub: string;
email: string;
name: string;
email_verified: string;
aud?: string;
exp?: string;
};
// email_verified can be returned as boolean true or string "true"
if (String(googleData.email_verified) !== 'true') {
return c.json({ error: 'Google email not verified' }, 400);
}
// Enforce audience when a client ID is configured (closes token-confusion attacks)
const expectedAud = process.env.GOOGLE_CLIENT_ID;
if (expectedAud) {
if (googleData.aud !== expectedAud) {
return c.json({ error: 'Invalid Google token audience' }, 400);
}
} else {
console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.');
}
// Reject expired tokens (defense-in-depth; tokeninfo also rejects them)
if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) {
return c.json({ error: 'Google token expired' }, 400);
}
const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Check by google_id
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
);
}
const now = getNow();
if (user) {
// User exists - link Google account if not already linked
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (!user.googleId) {
await (db as any)
.update(users)
.set({
googleId,
isClaimed: toDbBool(true),
accountStatus: 'active',
updatedAt: now,
})
.where(eq((users as any).id, user.id));
}
// Refresh user data
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
} else {
// Create new user
const firstUser = await isFirstUser();
const id = generateId();
const newUser = {
id,
email,
password: null,
name,
phone: null,
role: firstUser ? 'admin' : 'user',
languagePreference: null,
isClaimed: toDbBool(true),
googleId,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
user = newUser;
}
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
} catch (error) {
console.error('Google auth error:', error);
return c.json({ error: 'Failed to authenticate with Google' }, 500);
}
});
// Get current user
auth.get('/me', async (c) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
phone: user.phone,
isClaimed: user.isClaimed,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
accountStatus: user.accountStatus,
createdAt: user.createdAt,
},
});
});
// Change password (authenticated users)
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { currentPassword, newPassword } = c.req.valid('json');
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Verify current password if user has one
const existingHash = await getUserPasswordHash(user.id);
if (existingHash) {
const validPassword = await verifyPassword(currentPassword, existingHash);
if (!validPassword) {
return c.json({ error: 'Current password is incorrect' }, 400);
}
}
const hashedPassword = await hashPassword(newPassword);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
// Invalidate all previously issued JWTs so a stolen old token can't outlive the change,
// then hand the current client a fresh token so it stays logged in on this device.
await bumpTokenVersion(user.id);
const refreshedUser = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0);
return c.json({ message: 'Password changed successfully', token: newToken });
});
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
auth.post('/logout', async (c) => {
const user = await getAuthUser(c);
if (user) {
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
}
return c.json({ message: 'Logged out successfully' });
});
export default auth;
-97
View File
@@ -1,97 +0,0 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { auth } from '../lib/betterAuth.js';
import { validatePassword } from '../lib/passwordPolicy.js';
import { db, dbGet, users } from '../db/index.js';
import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
const authExt = new Hono();
const claimAccountSchema = z.object({
password: z.string().min(10, 'Password must be at least 10 characters'),
});
// Complete a progressive-account claim. The user arrives here already holding
// a session established by the claim magic link; this endpoint deliberately
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
// and is the ONLY endpoint that does.
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
}
const user = session.user as any;
if (user.banned || user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (user.isClaimed && user.accountStatus === 'active') {
return c.json({ error: 'Account is already claimed' }, 400);
}
const { password } = c.req.valid('json');
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
await auth.api.setPassword({
body: { newPassword: password },
headers: c.req.raw.headers,
});
// The magic link click proved email ownership
await (db as any)
.update(users)
.set({
isClaimed: toDbBool(true),
accountStatus: 'active',
emailVerified: true,
updatedAt: getNow(),
})
.where(eq((users as any).id, user.id));
return c.json({
message: 'Account claimed successfully!',
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: true,
phone: user.phone ?? null,
rucNumber: user.rucNumber ?? null,
languagePreference: user.languagePreference ?? null,
},
});
});
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
// exception to enumeration-safety, matching the legacy register/login UX that
// surfaced "this account can be claimed".
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
const email = c.req.query('email');
if (!email || !z.string().email().safeParse(email).success) {
return c.json({ canClaim: false });
}
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
);
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
return c.json({ canClaim });
});
export default authExt;
+45 -71
View File
@@ -1,12 +1,18 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
import { auth } from '../lib/betterAuth.js';
import { authSessions, authAccounts } from '../db/auth-schema.js';
import { getNow } from '../lib/utils.js';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const dashboard = new Hono();
@@ -44,7 +50,7 @@ dashboard.get('/profile', async (c) => {
isClaimed: user.isClaimed,
accountStatus: user.accountStatus,
hasPassword,
hasGoogleLinked: await hasGoogleAccount(user.id),
hasGoogleLinked: !!user.googleId,
memberSince: user.createdAt,
membershipDays,
createdAt: user.createdAt,
@@ -417,77 +423,49 @@ dashboard.get('/invoices', async (c) => {
// ==================== Security Routes ====================
// Get active sessions (Better Auth session table; validated per-request so
// this list is always live). Session tokens are never exposed to the client.
// Get active sessions
dashboard.get('/sessions', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessions = await dbAll<any>(
(db as any)
.select({
id: (authSessions as any).id,
userAgent: (authSessions as any).userAgent,
ipAddress: (authSessions as any).ipAddress,
createdAt: (authSessions as any).createdAt,
updatedAt: (authSessions as any).updatedAt,
expiresAt: (authSessions as any).expiresAt,
})
.from(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
gt((authSessions as any).expiresAt, new Date())
)
)
.orderBy(desc((authSessions as any).updatedAt))
);
const sessions = await getUserSessions(user.id);
return c.json({
sessions: sessions.map((s: any) => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
lastActiveAt: s.updatedAt,
lastActiveAt: s.lastActiveAt,
createdAt: s.createdAt,
expiresAt: s.expiresAt,
current: s.id === user.sessionId,
})),
});
});
// Revoke a specific session. Deleting the row is immediately effective:
// sessions are validated against the table on every request (no cookie cache).
// Revoke a specific session
dashboard.delete('/sessions/:id', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessionId = c.req.param('id');
await (db as any)
.delete(authSessions)
.where(
and(
eq((authSessions as any).id, sessionId),
eq((authSessions as any).userId, user.id)
)
);
await invalidateSession(sessionId, user.id);
return c.json({ message: 'Session revoked' });
});
// Revoke all other sessions (logout everywhere else); the current session
// stays valid so this device remains signed in.
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
// every previously issued JWT for this user, which is the actual enforcement
// mechanism (auth is stateless JWT, not DB-session based).
dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser;
await (db as any)
.delete(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
sql`${(authSessions as any).id} != ${user.sessionId}`
)
);
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
return c.json({ message: 'All other sessions revoked.' });
// Issue a fresh token so the current device stays signed in
const refreshed = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
return c.json({ message: 'All other sessions revoked.', token });
});
// Set password (for users without one)
@@ -504,21 +482,21 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
}
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
// policy hook does not cover it — validate explicitly.
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
try {
await auth.api.setPassword({
body: { newPassword: password },
headers: c.req.raw.headers,
});
} catch (err: any) {
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password set successfully' });
});
@@ -527,7 +505,7 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
dashboard.post('/unlink-google', async (c) => {
const user = (c as any).get('user') as AuthUser;
if (!(await hasGoogleAccount(user.id))) {
if (!user.googleId) {
return c.json({ error: 'Google account not linked' }, 400);
}
@@ -535,18 +513,14 @@ dashboard.post('/unlink-google', async (c) => {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
}
await (db as any)
.delete(authAccounts)
.where(
and(
eq((authAccounts as any).userId, user.id),
eq((authAccounts as any).providerId, 'google')
)
);
const now = getNow();
await (db as any)
.update(users)
.set({ updatedAt: getNow() })
.set({
googleId: null,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Google account unlinked' });
-443
View File
@@ -1,443 +0,0 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Env must be pinned before the db singleton is imported (dotenv never overrides).
const dir = mkdtempSync(join(tmpdir(), 'door-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
// Who the next request is from. Session auth itself is Better Auth's concern and
// has its own integration suite; this mock keeps the *role* check real so the
// tests can prove which endpoints door staff may reach.
let currentUser: { id: string; name: string; role: string } = STAFF;
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (roles && !roles.includes(currentUser.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', currentUser);
await next();
},
getAuthUser: async () => currentUser,
}));
/** Run one request as a given role, always restoring the default afterwards. */
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
vi.mock('../lib/email.js', () => ({
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
}));
let app: any;
let sqlite: any;
const EVENT_ID = 'evt-door-1';
const PRICE = 60000;
/** POST helper that mirrors how the door screen calls the API. */
async function post(path: string, body: unknown) {
const res = await app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
async function get(path: string) {
const res = await app.request(path);
return { status: res.status, body: await res.json() };
}
function seedTicket(row: {
id: string;
first: string;
last?: string | null;
status: string;
paymentStatus: string;
phone?: string | null;
bookingId?: string | null;
qr?: string;
}) {
sqlite
.prepare(
`INSERT INTO tickets (id, booking_id, user_id, event_id, attendee_first_name, attendee_last_name,
attendee_email, attendee_phone, status, payment_status, is_guest, qr_code, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
)
.run(
row.id,
row.bookingId ?? null,
'seed-user',
EVENT_ID,
row.first,
row.last ?? null,
`${row.id}@test.py`,
row.phone ?? null,
row.status,
row.paymentStatus,
row.qr ?? `QR-${row.id}`,
new Date().toISOString()
);
}
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
const doorRoutes = (await import('./door.js')).default;
app = new Hono();
app.route('/api/events', doorRoutes);
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'user', 0, 'unclaimed', ?, ?)`
)
.run('seed-user', 'seed@test.py', 'Seed User', now, now);
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'staff', 1, 'active', ?, ?)`
)
.run(STAFF.id, 'staff@test.py', STAFF.name, now, now);
sqlite
.prepare(
`INSERT INTO events (id, title, description, start_datetime, location, price, currency, capacity, status, created_at, updated_at)
VALUES (?, 'Door Night', 'desc', ?, 'Asuncion', ?, 'PYG', 2, 'published', ?, ?)`
)
.run(EVENT_ID, now, PRICE, now, now);
seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567' });
seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' });
})();
}, 120_000);
describe('door-attendees', () => {
it('returns everyone including cancelled, with group bookings flagged', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
expect(status).toBe(200);
expect(body.event.price).toBe(PRICE);
expect(body.attendees).toHaveLength(4);
const cancelled = body.attendees.find((a: any) => a.ticketId === 'tkt-cancelled');
expect(cancelled.status).toBe('cancelled');
const grouped = body.attendees.find((a: any) => a.ticketId === 'tkt-unpaid');
expect(grouped.isGroupBooking).toBe(true);
expect(grouped.amountDue).toBe(PRICE);
const solo = body.attendees.find((a: any) => a.ticketId === 'tkt-paid');
expect(solo.isGroupBooking).toBe(false);
expect(solo.amountDue).toBe(0);
});
it('is sorted alphabetically so an empty search is scrollable', async () => {
const { body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
const names = body.attendees.map((a: any) => a.fullName);
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })));
});
});
describe('door-checkin: existing ticket', () => {
it('checks in a paid attendee with no payment record touched', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
entryMethod: 'search',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(201);
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.checkinAt).toBeTruthy();
expect(body.attendee.checkedInBy).toBe(STAFF.name);
expect(body.payment).toBeNull();
const row = sqlite.prepare('SELECT status, checked_in_by_admin_id FROM tickets WHERE id = ?').get('tkt-paid');
expect(row.status).toBe('checked_in');
expect(row.checked_in_by_admin_id).toBe(STAFF.id);
});
it('replays an already-processed key instead of checking in twice', async () => {
const before = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
expect(after).toBe(before);
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-paid').n).toBe(0);
});
it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid',
payment: { method: 'cash', amount: PRICE },
entryMethod: 'search',
idempotencyKey: 'key-unpaid-cash',
});
expect(status).toBe(201);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.checkedIn).toBe(true);
expect(body.payment).toMatchObject({ method: 'cash', amount: PRICE });
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid');
expect(payment.source).toBe('door');
expect(payment.method).toBe('cash');
expect(payment.provider).toBe('cash');
expect(payment.status).toBe('paid');
expect(payment.paid_by_admin_id).toBe(STAFF.id);
});
it('takes a group payment at a multiple of the ticket price', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid-2',
payment: { method: 'transfer', amount: PRICE * 2 },
idempotencyKey: 'key-unpaid-2-transfer',
});
expect(body.payment.amount).toBe(PRICE * 2);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid-2');
expect(payment.provider).toBe('bank_transfer');
expect(payment.method).toBe('transfer');
expect(payment.amount).toBe(PRICE * 2);
});
it('reactivates a cancelled ticket through the same payment flow', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-cancelled',
payment: { method: 'bitcoin' },
idempotencyKey: 'key-cancelled-reactivate',
});
expect(body.attendee.status).toBe('checked_in');
expect(body.attendee.paymentStatus).toBe('paid');
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-cancelled');
// Bitcoin is recorded as already-paid Lightning: same trust model as cash,
// no invoice generated (see lib/doorPayments.ts).
expect(payment.provider).toBe('lightning');
expect(payment.method).toBe('bitcoin');
expect(payment.amount).toBe(PRICE);
});
it('rejects a ticket from another event', async () => {
const { status, body } = await post('/api/events/other-event/door-checkin', {
ticketId: 'tkt-paid',
idempotencyKey: 'key-wrong-event',
});
expect(status).toBe(404);
expect(body.error).toMatch(/Event not found/);
});
});
describe('door-checkin: walk-ins', () => {
it('creates a cash walk-in confirmed, paid and checked in with no email', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(201);
expect(body.action).toBe('walkin');
expect(body.attendee.fullName).toBe('Walk');
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.email).toBeNull();
const ticket = sqlite.prepare('SELECT * FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('checked_in');
expect(ticket.qr_code).toBeTruthy();
// A placeholder account keeps users.email unique without mailing anyone.
const account = sqlite.prepare('SELECT email FROM users WHERE id = ?').get(ticket.user_id);
expect(account.email).toMatch(/@doorentry\.local$/);
});
it('records a guest walk-in as a zero-amount comp', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Free', lastName: 'Guest' },
payment: { method: 'guest', amount: PRICE },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-guest',
});
expect(body.attendee.paymentStatus).toBe('comp');
expect(body.attendee.isGuest).toBe(true);
expect(body.payment.amount).toBe(0);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(0);
expect(payment.method).toBe('guest');
});
it('does not create a second ticket when the same walk-in key is retried', async () => {
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
});
it('warns rather than blocks once the event is over capacity', async () => {
// Capacity is 2 and several tickets already hold seats.
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Overflow' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-overflow',
});
expect(body.ok).toBe(true);
expect(body.warnings).toContain('at_capacity');
});
});
describe('undo', () => {
it('reverts a plain check-in to its previous state', async () => {
seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid' });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo',
idempotencyKey: 'key-undo-checkin',
});
expect(sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get('tkt-undo').status).toBe('checked_in');
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-undo-checkin',
});
expect(status).toBe(200);
expect(body.reverted).toBe('existing');
const row = sqlite.prepare('SELECT status, checkin_at FROM tickets WHERE id = ?').get('tkt-undo');
expect(row.status).toBe('confirmed');
expect(row.checkin_at).toBeNull();
});
it('removes the payment it created and restores the unpaid balance', async () => {
seedTicket({ id: 'tkt-undo-pay', first: 'Undo', last: 'Pay', status: 'confirmed', paymentStatus: 'unpaid' });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo-pay',
payment: { method: 'cash' },
idempotencyKey: 'key-undo-pay',
});
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(1);
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-pay' });
const row = sqlite.prepare('SELECT status, payment_status FROM tickets WHERE id = ?').get('tkt-undo-pay');
expect(row.status).toBe('confirmed');
expect(row.payment_status).toBe('unpaid');
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(0);
});
it('cancels a walk-in it created', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Mistake' },
payment: { method: 'cash' },
idempotencyKey: 'key-undo-walkin',
});
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
const ticket = sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('cancelled');
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.status).toBe('cancelled');
});
it('is safe to call twice and rejects an unknown key', async () => {
const repeat = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
expect(repeat.body.alreadyUndone).toBe(true);
const unknown = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'never-happened' });
expect(unknown.status).toBe(404);
});
});
describe('door-summary access', () => {
it('is hidden from door staff — whole-event takings are not door information', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
expect(status).toBe(403);
// The numbers must not leak in the body either: hiding the section in the UI
// alone would still expose them to anyone reading the network response.
expect(body).not.toHaveProperty('door');
expect(body).not.toHaveProperty('presale');
});
it('is available to admin and organizer', async () => {
for (const role of [ADMIN, ORGANIZER]) {
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status, `${role.role} should see door takings`).toBe(200);
}
});
it('still lets door staff do their job — list, check in and undo', async () => {
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
// Comp, so this ticket stays out of the revenue totals asserted below and
// the two tests cannot drift into each other through the shared database.
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-role',
idempotencyKey: 'key-role-check',
});
expect(checkin.status).toBe(201);
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-role-check',
});
expect(undo.status).toBe(200);
});
});
describe('door-summary', () => {
it('totals door takings by tender and splits them from pre-sale', async () => {
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status).toBe(200);
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
// cancelled and no longer count).
expect(body.door.byMethod.cash.count).toBe(3);
expect(body.door.byMethod.cash.total).toBe(PRICE * 3);
expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 });
expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE });
expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 });
expect(body.door.total).toBe(PRICE * 6);
// Settled tickets with no door payment against them: tkt-paid, plus tkt-undo,
// whose door check-in was undone and which is a pre-paid ticket again.
expect(body.presale.count).toBe(2);
expect(body.presale.total).toBe(PRICE * 2);
expect(body.total).toBe(PRICE * 8);
expect(body.door.lines.length).toBe(body.door.count);
expect(body.door.lines[0]).toHaveProperty('name');
});
});
-663
View File
@@ -1,663 +0,0 @@
// Door check-in screen (admin/scanner) API.
//
// At the door, check-in and ticket creation are the same action, so everything
// here is written for one-tap speed on a phone with unreliable venue wifi:
//
// GET /:eventId/door-attendees full attendee list, fetched once and searched
// client-side so typing never hits the network
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
// payment, or creates a walk-in, atomically
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
// split (admin/organizer only)
//
// Every write carries a client-generated idempotencyKey. The key is inserted in
// the same transaction as the writes, so a double tap or a retry after a timeout
// can never produce a second ticket, a second payment or a double check-in — the
// replay returns the original response instead.
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, and, inArray, sql } from 'drizzle-orm';
import {
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
} from '../db/index.js';
import { requireAuth } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
import {
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
paymentStatusForMethod, type DoorPaymentMethod,
} from '../lib/doorPayments.js';
import emailService from '../lib/email.js';
const doorRouter = new Hono();
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
// Whole-event money is management information, not door information: door staff
// reconcile their own shift from the session feed the client keeps locally, and
// never see what the event took overall. Matches the existing convention for
// revenue aggregates (admin/export/financial, admin/analytics).
const REVENUE_ROLES = ['admin', 'organizer'] as const;
const IDEMPOTENCY_SCOPE = 'door-checkin';
// ==================== Shared helpers ====================
const num = (v: any): number => {
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
return Number.isFinite(n) ? n : 0;
};
const iso = (v: any): string | null => {
if (!v) return null;
return v instanceof Date ? v.toISOString() : String(v);
};
function fullName(ticket: any): string {
return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
}
/**
* The row shape the door screen renders. Returned both by the preload list and
* by every write, so the client can splice an updated attendee straight back
* into its in-memory list without a refetch.
*/
function toDoorAttendee(
ticket: any,
opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
) {
return {
ticketId: ticket.id,
firstName: ticket.attendeeFirstName,
lastName: ticket.attendeeLastName || null,
fullName: fullName(ticket),
email: ticket.attendeeEmail || null,
phone: ticket.attendeePhone || null,
status: ticket.status,
paymentStatus: ticket.paymentStatus,
isGuest: !!ticket.isGuest,
checkedIn: ticket.status === 'checked_in',
checkinAt: iso(ticket.checkinAt),
checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
bookingId: ticket.bookingId || null,
isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
doorMethod: opts.doorMethod ?? null,
qrCode: ticket.qrCode || null,
createdAt: iso(ticket.createdAt),
};
}
async function loadEvent(eventId: string | undefined) {
if (!eventId) return null;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) return null;
return {
...event,
price: num(event.price),
capacity: Number(event.capacity),
};
}
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
const unique = [...new Set(adminIds.filter(Boolean))];
if (unique.length === 0) return new Map();
const rows = await dbAll<any>(
(db as any)
.select({ id: (users as any).id, name: (users as any).name })
.from(users)
.where(inArray((users as any).id, unique))
);
return new Map(rows.map((r: any) => [r.id, r.name]));
}
/** Seats currently held for an event, used only to warn (never to block) at the door. */
async function seatsHeld(eventId: string): Promise<number> {
const row = await dbGet<any>(seatHolderCountQuery(db, eventId));
return Number(row?.count || 0);
}
// ==================== GET /:eventId/door-attendees ====================
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
// tickets are included on purpose: staff must be able to see and reactivate them.
doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async (c) => {
const eventId = c.req.param('eventId');
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
const rows = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId))
);
// A booking id shared by more than one ticket marks a group booking, which is
// the usual reason an otherwise-confirmed attendee still shows as unpaid.
const bookingCounts = new Map<string, number>();
for (const t of rows) {
if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1);
}
const groupBookingIds = new Set(
[...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id)
);
const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId));
// Door tender per ticket, so a row already settled at the door shows how.
// Joined on the event rather than on a list of ticket ids: the id list would
// grow with the guest list and eventually blow the statement parameter limit.
const doorMethods = new Map<string, string>();
const doorPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, method: (payments as any).method })
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(and(
eq((tickets as any).eventId, eventId),
eq((payments as any).source, 'door')
))
);
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
const attendees = rows
.map((t: any) => toDoorAttendee(t, {
price: event.price,
groupBookingIds,
adminNames,
doorMethod: doorMethods.get(t.id) || null,
}))
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
const checkedIn = attendees.filter((a) => a.checkedIn).length;
const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length;
return c.json({
event: {
id: event.id,
title: event.title,
price: event.price,
currency: event.currency,
capacity: event.capacity,
},
attendees,
stats: { checkedIn, totalActive, capacity: event.capacity },
});
});
// ==================== POST /:eventId/door-checkin ====================
const doorCheckinSchema = z.object({
// Existing ticket to check in (and optionally settle), or…
ticketId: z.string().optional(),
// …a walk-in to create. Only a first name is ever required.
attendee: z.object({
firstName: z.string().trim().min(1).max(255),
lastName: z.string().trim().max(255).optional().or(z.literal('')),
phone: z.string().trim().max(50).optional().or(z.literal('')),
email: z.string().trim().email().optional().or(z.literal('')),
ruc: z.string().trim().max(15).optional().or(z.literal('')),
}).optional(),
payment: z.object({
method: z.enum(DOOR_PAYMENT_METHODS),
// Omitted means "one ticket at event price"; a multiple covers someone
// paying for their whole group in one go.
amount: z.number().min(0).optional(),
}).optional(),
// How the attendee reached this action, for the session feed.
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
idempotencyKey: z.string().min(8).max(128),
}).refine((d) => !!d.ticketId || !!d.attendee, {
message: 'Either ticketId or attendee is required',
path: ['ticketId'],
});
/** Undo instructions recorded alongside each processed idempotency key. */
type UndoState =
| {
kind: 'created';
ticketId: string;
paymentId: string;
}
| {
kind: 'existing';
ticketId: string;
prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean };
createdPaymentId?: string;
prevPayment?: {
id: string; provider: string; amount: number; status: string; reference: string | null;
paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null;
};
};
/** A replay of a key we already processed returns the original response verbatim. */
async function findProcessedKey(key: string) {
return dbGet<any>(
(db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key))
);
}
doorRouter.post(
'/:eventId/door-checkin',
requireAuth([...STAFF_ROLES]),
zValidator('json', doorCheckinSchema),
async (c) => {
const eventId = c.req.param('eventId');
const data = c.req.valid('json');
const adminUser = (c as any).get('user');
const existingKey = await findProcessedKey(data.idempotencyKey);
if (existingKey) {
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
}
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
const now = getNow();
const nowIso = new Date().toISOString();
const method = data.payment?.method as DoorPaymentMethod | undefined;
const requestedAmount = data.payment?.amount ?? event.price;
const ops: TxOp[] = [];
let undoState: UndoState;
let action: 'checkin' | 'walkin';
let ticketRow: any;
let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null;
let emailTicketId: string | null = null;
if (data.ticketId) {
// ---- Existing ticket: settle (optionally) and check in ----
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId))
);
if (!ticket) return c.json({ error: 'Ticket not found' }, 404);
if (ticket.eventId !== eventId) {
return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400);
}
action = 'checkin';
const prevTicket = {
status: ticket.status,
checkinAt: iso(ticket.checkinAt),
checkedInByAdminId: ticket.checkedInByAdminId || null,
paymentStatus: ticket.paymentStatus,
isGuest: !!ticket.isGuest,
};
const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket };
const ticketUpdate: Record<string, any> = {};
if (method) {
const amount = amountForMethod(method, requestedAmount);
const tender = DOOR_TENDERS[method];
ticketUpdate.paymentStatus = paymentStatusForMethod(method);
if (method === 'guest') ticketUpdate.isGuest = toDbBool(true);
const existingPayment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id))
);
if (existingPayment) {
undo.prevPayment = {
id: existingPayment.id,
provider: existingPayment.provider,
amount: num(existingPayment.amount),
status: existingPayment.status,
reference: existingPayment.reference || null,
paidAt: iso(existingPayment.paidAt),
paidByAdminId: existingPayment.paidByAdminId || null,
source: existingPayment.source || 'presale',
method: existingPayment.method || null,
};
ops.push(updateOp(payments, {
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(method),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method,
updatedAt: now,
}, eq((payments as any).id, existingPayment.id)));
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency };
} else {
const paymentId = generateId();
undo.createdPaymentId = paymentId;
ops.push(insertOp(payments, {
id: paymentId,
ticketId: ticket.id,
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(method),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method,
createdAt: now,
updatedAt: now,
}));
paymentSummary = { id: paymentId, method, amount, currency: event.currency };
}
}
// Check in. An already-checked-in ticket keeps its original timestamp so
// staff can still tell the person when they actually entered.
if (ticket.status !== 'checked_in') {
ticketUpdate.status = 'checked_in';
ticketUpdate.checkinAt = now;
ticketUpdate.checkedInByAdminId = adminUser?.id || null;
}
if (Object.keys(ticketUpdate).length > 0) {
ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id)));
}
undoState = undo;
ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt };
} else {
// ---- Walk-in: born confirmed, settled and checked in, in one write ----
const attendee = data.attendee!;
action = 'walkin';
const tenderMethod: DoorPaymentMethod = method || 'cash';
const tender = DOOR_TENDERS[tenderMethod];
const amount = amountForMethod(tenderMethod, requestedAmount);
const hasEmail = !!(attendee.email && attendee.email.trim());
const firstNameValue = attendee.firstName.trim();
const lastNameValue = attendee.lastName?.trim() || null;
const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue;
// No email is the fast path; a placeholder keeps the users.email unique
// constraint satisfied without ever mailing anyone.
// Normalized: Better Auth lowercases every lookup it makes, and the
// users.email unique index is case-sensitive, so a mixed-case address
// written here would be invisible to sign-in and Google linking.
const accountEmail = normalizeEmail(
hasEmail
? attendee.email!
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`
);
let user = hasEmail
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
: null;
if (!user) {
const userId = generateId();
user = { id: userId, email: accountEmail };
ops.push(insertOp(users, {
id: userId,
email: accountEmail,
password: null,
name: displayName,
phone: attendee.phone?.trim() || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
}));
}
const ticketId = generateId();
const paymentId = generateId();
const newTicket = {
id: ticketId,
bookingId: null,
userId: user.id,
eventId,
attendeeFirstName: firstNameValue,
attendeeLastName: lastNameValue,
attendeeEmail: hasEmail ? attendee.email!.trim() : null,
attendeePhone: attendee.phone?.trim() || null,
attendeeRuc: attendee.ruc?.trim() || null,
preferredLanguage: null,
status: 'checked_in',
paymentStatus: paymentStatusForMethod(tenderMethod),
isGuest: toDbBool(tenderMethod === 'guest'),
qrCode: generateTicketCode(),
checkinAt: now,
checkedInByAdminId: adminUser?.id || null,
adminNote: null,
createdAt: now,
};
ops.push(insertOp(tickets, newTicket));
ops.push(insertOp(payments, {
id: paymentId,
ticketId,
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(tenderMethod),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method: tenderMethod,
createdAt: now,
updatedAt: now,
}));
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency };
undoState = { kind: 'created', ticketId, paymentId };
ticketRow = newTicket;
// Only mail people who actually gave an address; no QR for the rest.
if (hasEmail) emailTicketId = ticketId;
}
// Staff at the door is the authority: a full event is a warning, never a block.
const held = await seatsHeld(eventId);
const atCapacity = event.capacity > 0 && held >= event.capacity;
const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]);
const responseBody = {
ok: true,
action,
attendee: toDoorAttendee(ticketRow, {
price: event.price,
groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []),
adminNames,
doorMethod: paymentSummary?.method || null,
}),
payment: paymentSummary,
warnings: atCapacity ? ['at_capacity'] : [],
idempotencyKey: data.idempotencyKey,
processedAt: nowIso,
};
// The key row goes in with the writes, so two concurrent replays of the same
// key cannot both commit — the loser hits the primary-key conflict below.
ops.unshift(insertOp(idempotencyKeys, {
key: data.idempotencyKey,
scope: IDEMPOTENCY_SCOPE,
result: JSON.stringify(responseBody),
undoState: JSON.stringify(undoState),
undoneAt: null,
createdAt: now,
}));
try {
await runOps(ops);
} catch (err: any) {
const replay = await findProcessedKey(data.idempotencyKey);
if (replay) {
return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt });
}
throw err;
}
if (emailTicketId) {
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
console.error('[Email] Failed to send door walk-in confirmation:', err);
});
}
return c.json(responseBody, 201);
}
);
// ==================== POST /:eventId/door-checkin/undo ====================
// Reverses exactly what the keyed action did — nothing more. This is what makes
// the door screen safe to run without a single confirm dialog.
doorRouter.post(
'/:eventId/door-checkin/undo',
requireAuth([...STAFF_ROLES]),
zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
async (c) => {
const { idempotencyKey } = c.req.valid('json');
const record = await findProcessedKey(idempotencyKey);
if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404);
if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true });
const undo = JSON.parse(record.undoState || 'null') as UndoState | null;
if (!undo) return c.json({ error: 'This action cannot be undone' }, 400);
const now = getNow();
const ops: TxOp[] = [];
if (undo.kind === 'created') {
// Walk-ins created here are cancelled, not deleted: the row stays as an
// audit trail and can be reactivated from the same screen.
ops.push(updateOp(tickets, {
status: 'cancelled',
checkinAt: null,
checkedInByAdminId: null,
}, eq((tickets as any).id, undo.ticketId)));
ops.push(updateOp(payments, {
status: 'cancelled',
paidAt: null,
updatedAt: now,
}, eq((payments as any).id, undo.paymentId)));
} else {
ops.push(updateOp(tickets, {
status: undo.prevTicket.status,
checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null,
checkedInByAdminId: undo.prevTicket.checkedInByAdminId,
paymentStatus: undo.prevTicket.paymentStatus,
isGuest: toDbBool(undo.prevTicket.isGuest),
}, eq((tickets as any).id, undo.ticketId)));
if (undo.createdPaymentId) {
ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId)));
} else if (undo.prevPayment) {
const prev = undo.prevPayment;
ops.push(updateOp(payments, {
provider: prev.provider,
amount: prev.amount,
status: prev.status,
reference: prev.reference,
paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null,
paidByAdminId: prev.paidByAdminId,
source: prev.source,
method: prev.method,
updatedAt: now,
}, eq((payments as any).id, prev.id)));
}
}
ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey)));
await runOps(ops);
return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind });
}
);
// ==================== GET /:eventId/door-summary ====================
// End-of-night reconciliation: what was taken at the door, by tender, plus the
// pre-sale/door split the event dashboard shows.
doorRouter.get('/:eventId/door-summary', requireAuth([...REVENUE_ROLES]), async (c) => {
const eventId = c.req.param('eventId');
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
// Door payments settled for this event, with the attendee attached so the
// session feed can show who each line belongs to.
const rows = await dbAll<any>(
(db as any)
.select({
paymentId: (payments as any).id,
ticketId: (tickets as any).id,
method: (payments as any).method,
amount: (payments as any).amount,
paidAt: (payments as any).paidAt,
firstName: (tickets as any).attendeeFirstName,
lastName: (tickets as any).attendeeLastName,
ticketStatus: (tickets as any).status,
})
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(and(
eq((tickets as any).eventId, eventId),
eq((payments as any).source, 'door'),
eq((payments as any).status, 'paid')
))
);
const byMethod: Record<string, { count: number; total: number }> = {};
for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 };
let doorTotal = 0;
for (const r of rows) {
const key = (r.method && byMethod[r.method]) ? r.method : 'cash';
const amount = num(r.amount);
byMethod[key].count += 1;
byMethod[key].total += amount;
doorTotal += amount;
}
// Pre-sale revenue keeps the dashboard's existing definition — settled tickets
// at event price — minus anything that was actually taken at the door.
const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
const settled = await dbAll<any>(
(db as any)
.select({ id: (tickets as any).id })
.from(tickets)
.where(and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).paymentStatus, 'paid'),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
))
);
const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length;
const presaleTotal = presaleCount * event.price;
return c.json({
eventId,
currency: event.currency,
price: event.price,
door: {
count: rows.length,
total: doorTotal,
byMethod,
lines: rows
.map((r: any) => ({
paymentId: r.paymentId,
ticketId: r.ticketId,
name: `${r.firstName} ${r.lastName || ''}`.trim(),
method: r.method || 'cash',
amount: num(r.amount),
paidAt: iso(r.paidAt),
}))
.sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')),
},
presale: { count: presaleCount, total: presaleTotal },
total: presaleTotal + doorTotal,
});
});
export default doorRouter;
+4 -25
View File
@@ -172,13 +172,6 @@ const updateEventSchema = baseEventSchema.partial().refine(
eventsRouter.get('/', async (c) => {
const status = c.req.query('status');
const upcoming = c.req.query('upcoming');
// Pagination is opt-in: callers that pass neither page nor pageSize (public
// pages, admin filter dropdowns) still get the full list.
const pageParam = c.req.query('page');
const pageSizeParam = c.req.query('pageSize');
const paginated = pageParam !== undefined || pageSizeParam !== undefined;
const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1);
const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200);
// Only privileged users may see non-public events (drafts, archived, etc.).
// Anonymous/regular callers are restricted to published events regardless of
@@ -202,24 +195,12 @@ eventsRouter.get('/', async (c) => {
conditions.push(eq((events as any).status, 'published'));
}
const whereClause = conditions.length === 0
? undefined
: conditions.length === 1 ? conditions[0] : and(...conditions);
let query = (db as any).select().from(events);
if (whereClause) query = query.where(whereClause);
query = query.orderBy(desc((events as any).startDatetime));
let total: number | undefined;
if (paginated) {
let countQuery = (db as any).select({ count: sql`count(*)` }).from(events);
if (whereClause) countQuery = countQuery.where(whereClause);
const totalRow = await dbGet<any>(countQuery);
total = Number(totalRow?.count || 0);
query = query.limit(pageSize).offset((page - 1) * pageSize);
if (conditions.length > 0) {
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
}
const result = await dbAll<any>(query);
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
// Single grouped query for seat counts across all events (avoids N+1: previously
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
@@ -246,9 +227,7 @@ eventsRouter.get('/', async (c) => {
};
});
return paginated
? c.json({ events: eventsWithCounts, total, page, pageSize })
: c.json({ events: eventsWithCounts });
return c.json({ events: eventsWithCounts });
});
// Get single event (public) - resolves by id, canonical slug, or historical alias
+1 -1
View File
@@ -667,7 +667,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
}
try {
const result = await emailService.sendPaymentReminder(payment.id);
const result = await emailService.sendPaymentReminder(id);
if (result.success) {
const now = getNow();
+39 -76
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
@@ -148,12 +148,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, 400);
}
// Find or create user. The account row is keyed on the normalized address so
// it stays reachable from Better Auth (which lowercases every lookup) —
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
const accountEmail = normalizeEmail(data.email);
// Find or create user
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
const now = getNow();
@@ -166,16 +163,13 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const userId = generateId();
user = {
id: userId,
email: accountEmail,
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
email: data.email,
password: '', // No password for guest bookings
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
rucNumber,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
@@ -551,24 +545,20 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
);
const timezone = settings?.timezone || 'America/Asuncion';
const ticketsData = confirmedTickets.map((ticket: any) => {
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
return {
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
locale,
};
});
const ticketsData = confirmedTickets.map((ticket: any) => ({
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
}));
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
@@ -632,22 +622,19 @@ ticketsRouter.get('/:id/pdf', async (c) => {
);
const timezone = settings?.timezone || 'America/Asuncion';
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
const pdfBuffer = await generateTicketPDF({
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
title: event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
locale,
});
// Set response headers for PDF download
@@ -1174,7 +1161,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
// Send confirmation emails asynchronously (don't block the response)
Promise.all([
emailService.sendBookingConfirmation(ticket.id),
emailService.sendBookingConfirmation(id),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
@@ -1428,10 +1415,9 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
? data.email.trim()
: `door-${generateId()}@doorentry.local`;
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
// Find or create user
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
const adminFullName = data.lastName && data.lastName.trim()
@@ -1442,15 +1428,12 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
const userId = generateId();
user = {
id: userId,
email: accountEmail,
password: null,
email: attendeeEmail,
password: '',
name: adminFullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
@@ -1540,18 +1523,14 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
// type drives payment handling:
// paid — email required; paid cash payment; confirmation email + QR sent
// door — paid in cash at the door; all fields optional; counts toward revenue;
// confirmation email only when an email is provided
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
// pay-link (Bancard/TPago) email sent when an email is provided
// guest — free comp ticket, not counted in revenue; confirmation email only
// when an email is provided
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
type: z.enum(['paid', 'door', 'unpaid', 'guest']),
// Door walk-ins can be logged with nothing filled in, so firstName is only
// required for the other types
firstName: z.string().optional().or(z.literal('')),
type: z.enum(['paid', 'unpaid', 'guest']),
firstName: z.string().min(1),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
@@ -1561,9 +1540,6 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
message: 'Email is required for paid tickets',
path: ['email'],
}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), {
message: 'First name is required',
path: ['firstName'],
})), async (c) => {
const data = c.req.valid('json');
@@ -1583,31 +1559,25 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
? data.email!.trim()
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
// Nameless door walk-ins still need a display name on the ticket
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in';
const fullName = data.lastName && data.lastName.trim()
? `${firstName} ${data.lastName.trim()}`
: firstName;
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
// Find or create user
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
if (!user) {
const userId = generateId();
user = {
id: userId,
email: accountEmail,
password: null,
email: attendeeEmail,
password: '',
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
@@ -1634,13 +1604,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const ticketId = generateId();
const qrCode = generateTicketCode();
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid';
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: firstName,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
@@ -1657,7 +1627,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
await (db as any).insert(tickets).values(newTicket);
// Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid
const paymentId = generateId();
const newPayment = data.type === 'unpaid'
? {
@@ -1680,11 +1650,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency,
status: 'paid',
reference: data.type === 'guest'
? 'Guest invite'
: data.type === 'door'
? 'Paid at door'
: 'Manual ticket',
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
@@ -1693,8 +1659,8 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
await (db as any).insert(payments).values(newPayment);
// Emails (asynchronous): paid always confirms; door/guest confirm only when an
// email exists; unpaid sends the TPago (Bancard) pay-link instructions instead
// Emails (asynchronous): paid always confirms; guest confirms when an email
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead
if (data.type === 'unpaid') {
if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => {
@@ -1717,9 +1683,6 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const messages: Record<string, string> = {
paid: 'Ticket created — confirmation email sent',
door: hasEmail
? 'Ticket created — paid at the door, confirmation email sent'
: 'Ticket created — paid at the door',
unpaid: hasEmail
? 'Unpaid ticket created — payment link sent'
: 'Unpaid ticket created — collect payment at the door',
+1 -19
View File
@@ -4,7 +4,6 @@ import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { authSessions } from '../db/auth-schema.js';
import { getNow, toDbDate } from '../lib/utils.js';
interface UserContext {
@@ -176,28 +175,11 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'User not found' }, 404);
}
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
// sign-in is blocked at the auth layer too, and kill live sessions on
// suspension so it takes effect immediately (sessions are DB-validated on
// every request by both the backend and the photo API).
const statusMirror: Record<string, any> = {};
if (data.accountStatus === 'suspended') {
statusMirror.banned = true;
statusMirror.banReason = 'Suspended by admin';
} else if (data.accountStatus) {
statusMirror.banned = false;
statusMirror.banReason = null;
}
await (db as any)
.update(users)
.set({ ...data, ...statusMirror, updatedAt: getNow() })
.set({ ...data, updatedAt: getNow() })
.where(eq((users as any).id, id));
if (data.accountStatus === 'suspended') {
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
}
const updated = await dbGet(
(db as any)
.select({
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": false,
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
-109
View File
@@ -1,109 +0,0 @@
# ============================================================
# Spanglish Community - Photo Gallery API
# photos.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name photos.spanglishcommunity.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://photos.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name photos.spanglishcommunity.com;
# Photos can be larger than typical JSON payloads
client_max_body_size 25m;
# SSL
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_photo_access.log;
error_log /var/log/nginx/spanglish_photo_error.log;
# CORS Configuration
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Ensure 429 (rate limited) returns JSON + CORS
error_page 429 = @rate_limited;
location @rate_limited {
default_type application/json;
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
return 429 '{"error":"Too many requests. Please slow down."}';
}
location / {
limit_req zone=spanglish_photo_limit burst=40 nodelay;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Buffer large image uploads to disk rather than memory
proxy_request_buffering on;
proxy_max_temp_file_size 1024m;
}
}
-146
View File
@@ -1,146 +0,0 @@
# ============================================================
# Spanglish Community - Backend API
# api.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name api.spanglishcommunity.com;
# ACME
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Force HTTPS
location / {
return 301 https://api.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name api.spanglishcommunity.com;
# Upload size limit (avoid nginx 413 on media uploads)
# Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB).
client_max_body_size 20m;
# SSL
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security (API)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# Logs
access_log /var/log/nginx/spanglish_api_access.log;
error_log /var/log/nginx/spanglish_api_error.log;
# CORS Configuration (set once, used everywhere)
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
# Add CORS headers to all responses (including nginx-generated errors)
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked")
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Photo gallery service (photo-api, port 3003). ^~ wins over the "/"
# prefix below, so /api/photos/* reaches the Go photo-api instead of the
# Node backend (which has no photo routes and would 404). The admin UI
# calls this cross-origin via NEXT_PUBLIC_API_URL, so preflight + CORS
# must be handled here just like location / below.
location ^~ /api/photos/ {
limit_req zone=spanglish_api_limit burst=50 nodelay;
# Handle preflight OPTIONS requests (add_header inside if{} does NOT
# inherit server-level headers, so repeat all CORS headers here).
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Strip CORS headers from the service (nginx handles CORS here)
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
# Photo batches can be large; allow bigger bodies + unbuffered upload.
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
location / {
limit_req zone=spanglish_api_limit burst=50 nodelay;
# Handle preflight OPTIONS requests
# NOTE: add_header inside if{} does NOT inherit server-level headers,
# so we must repeat all CORS headers here.
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Strip CORS headers from backend (nginx handles CORS at server level)
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
}
-115
View File
@@ -1,115 +0,0 @@
# ============================================================
# Spanglish Community - Frontend
# spanglishcommunity.com / www
# ============================================================
server {
listen 80;
server_name spanglishcommunity.com www.spanglishcommunity.com;
# ACME
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# Force HTTPS
location / {
return 301 https://spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name spanglishcommunity.com www.spanglishcommunity.com;
# Upload size limit (covers same-origin /api uploads via this vhost)
client_max_body_size 20m;
# SSL
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_frontend_access.log;
error_log /var/log/nginx/spanglish_frontend_error.log;
# Proxy /api/photos to the photo-api (Go service, port 3003). ^~ wins over
# the /api prefix below so same-origin image/gallery requests reach the
# photo-api instead of the Node backend (which has no photo routes -> 404).
location ^~ /api/photos/ {
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Photo uploads/batches can be large; allow bigger bodies.
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Let the photo-api set Cache-Control per image visibility (public vs.
# token-gated) rather than forcing a cache policy here.
}
# Proxy /api to backend
location /api {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
# Proxy /uploads to backend
location /uploads {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache static files
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Frontend App
location / {
proxy_pass http://spanglish_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket / HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
}
}
-108
View File
@@ -1,108 +0,0 @@
# ============================================================
# Spanglish Community - Photo Gallery API
# photos.spanglishcommunity.com
# ============================================================
server {
listen 80;
server_name photos.spanglishcommunity.com;
location /.well-known/acme-challenge/ {
}
location / {
return 301 https://photos.spanglishcommunity.com$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name photos.spanglishcommunity.com;
# Photos can be larger than typical JSON payloads
client_max_body_size 25m;
# SSL
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logs
access_log /var/log/nginx/spanglish_photo_access.log;
error_log /var/log/nginx/spanglish_photo_error.log;
# CORS Configuration
set $cors_origin "";
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
set $cors_origin $http_origin;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
# Ensure 413 returns JSON + CORS
error_page 413 = @payload_too_large;
location @payload_too_large {
default_type application/json;
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Ensure 429 (rate limited) returns JSON + CORS
error_page 429 = @rate_limited;
location @rate_limited {
default_type application/json;
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
return 429 '{"error":"Too many requests. Please slow down."}';
}
location / {
limit_req zone=spanglish_photo_limit burst=40 nodelay;
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://spanglish_photo_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
proxy_hide_header 'Access-Control-Expose-Headers';
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
# Buffer large image uploads to disk rather than memory
proxy_request_buffering on;
proxy_max_temp_file_size 1024m;
}
}
@@ -1,12 +0,0 @@
upstream spanglish_frontend {
server 127.0.0.1:3019;
}
upstream spanglish_backend {
server 127.0.0.1:3018;
}
upstream spanglish_photo_api {
server 127.0.0.1:3003;
}
limit_req_zone $binary_remote_addr zone=spanglish_photo_limit:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=spanglish_api_limit:10m rate=30r/s;
-1
View File
@@ -14,7 +14,6 @@
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"better-auth": "1.6.25",
"clsx": "^2.1.1",
"html5-qrcode": "^2.3.8",
"next": "^14.2.4",
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -9,48 +9,29 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { authApi } from '@/lib/api';
import { authClient } from '@/lib/auth-client';
import toast from 'react-hot-toast';
/**
* Progressive-account claim. The claim email contains a magic link that signs
* the user in (via /auth/magic-link) and redirects here; this page then asks
* for a password and completes the claim against /api/auth-ext/claim-account.
*/
function ClaimAccountContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const { refreshUser } = useAuth();
const { setAuthData } = useAuth();
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
useEffect(() => {
let cancelled = false;
authClient
.getSession()
.then(({ data }) => {
if (cancelled) return;
const user: any = data?.user;
setHasSession(!!user);
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, []);
const token = searchParams.get('token');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
return;
}
if (formData.password !== formData.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
@@ -68,8 +49,8 @@ function ClaimAccountContent() {
setLoading(true);
try {
await authApi.confirmClaimAccount(formData.password);
await refreshUser();
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
setAuthData({ user: result.user, token: result.token });
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
router.push('/dashboard');
} catch (error: any) {
@@ -79,21 +60,7 @@ function ClaimAccountContent() {
}
};
if (checking) {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
if (alreadyClaimed) {
// Signed-in and already active: nothing to claim
router.push('/dashboard');
return null;
}
if (!hasSession) {
if (!token) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
@@ -109,8 +76,8 @@ function ClaimAccountContent() {
</h2>
<p className="text-gray-600 mb-6">
{language === 'es'
? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
? 'Este enlace de activación no es válido o ha expirado.'
: 'This activation link is invalid or has expired.'}
</p>
<Link href="/login">
<Button>
@@ -6,8 +6,6 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { safeInternalPath } from '@/lib/safeRedirect';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
function MagicLinkContent() {
@@ -20,7 +18,6 @@ function MagicLinkContent() {
const verificationAttempted = useRef(false);
const token = searchParams.get('token');
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
useEffect(() => {
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
@@ -37,17 +34,11 @@ function MagicLinkContent() {
const verifyToken = async () => {
try {
const user = await loginWithMagicLink(token!);
await loginWithMagicLink(token!);
setStatus('success');
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
// Unclaimed accounts must finish the claim (set a password) before the
// rest of the API will accept their session.
const destination =
user && (user.isClaimed === false || user.accountStatus === 'unclaimed')
? '/auth/claim-account'
: callbackURL;
setTimeout(() => {
redirectAfterAuth(destination);
router.push('/dashboard');
}, 1500);
} catch (err: any) {
setStatus('error');
@@ -25,7 +25,7 @@ interface AccountTabProps {
*/
export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage();
const { user, updateUser } = useAuth();
const { user, updateUser, logout } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
@@ -187,17 +187,15 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
if (
!confirm(
locale === 'es'
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
: 'Log out of all other sessions? This session stays signed in.'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
)
)
return;
try {
await dashboardApi.revokeAllSessions();
toast.success(
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
);
loadData();
toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
@@ -479,7 +477,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
</p>
) : (
<div className="space-y-3">
{sessions.map((session) => (
{sessions.map((session, index) => (
<div
key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
@@ -496,7 +494,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{session.current ? (
{index === 0 ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'}
</span>
+3 -5
View File
@@ -23,7 +23,7 @@ type Tab = 'overview' | 'tickets' | 'payments' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { locale } = useLanguage();
const { user, isLoading: authLoading } = useAuth();
const { user, isLoading: authLoading, token } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
@@ -36,13 +36,11 @@ export default function DashboardPage() {
router.push('/login');
return;
}
// Auth rides on the httpOnly session cookie; once the user has resolved
// the API calls are authenticated automatically.
if (user) {
if (user && token) {
loadDashboardData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authLoading]);
}, [user, authLoading, token]);
const loadDashboardData = async () => {
setLoading(true);
@@ -1,6 +0,0 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the event gallery.
export default function Loading() {
return <GallerySkeleton />;
}
+7 -84
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -11,20 +11,14 @@ import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { authApi } from '@/lib/api';
import { safeInternalPath } from '@/lib/safeRedirect';
import {
clearRedirectAttempt,
didRedirectBounce,
redirectAfterAuth,
} from '@/lib/authRedirect';
import toast from 'react-hot-toast';
function LoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { t, locale: language } = useLanguage();
const { login, user, isLoading: authLoading } = useAuth();
const { login } = useAuth();
const [loading, setLoading] = useState(false);
const [redirecting, setRedirecting] = useState(false);
const [bounced, setBounced] = useState(false);
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [formData, setFormData] = useState({
@@ -35,27 +29,6 @@ function LoginContent() {
// Check for redirect after login (only same-origin relative paths are honoured)
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
// Send an already-signed-in visitor on to their destination — and detect the case
// where that destination bounced them back here, which otherwise looks like the
// login page silently ignoring a successful sign-in.
useEffect(() => {
if (authLoading || redirecting) return;
if (!user) {
// Signed out on the login page is a clean slate.
clearRedirectAttempt();
return;
}
if (didRedirectBounce(redirectTo)) {
setBounced(true);
return;
}
setRedirecting(true);
redirectAfterAuth(redirectTo);
}, [authLoading, redirecting, user, redirectTo]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -63,12 +36,10 @@ function LoginContent() {
try {
await login(formData.email, formData.password);
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
// Deliberately leaves `loading` set: the button must stay disabled until the
// browser replaces this page.
setRedirecting(true);
redirectAfterAuth(redirectTo);
router.push(redirectTo);
} catch (error: any) {
toast.error(error.message || t('auth.errors.invalidCredentials'));
} finally {
setLoading(false);
}
};
@@ -96,38 +67,6 @@ function LoginContent() {
}
};
// The destination sent us back here even though the session is valid. Say so, rather
// than re-showing a form that appears to do nothing. The retry link is a plain <a> so
// it is a full page load, like every other navigation out of this page.
if (bounced) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
<h1 className="text-2xl font-bold">{t('auth.login.redirectBlocked')}</h1>
{user && <p className="mt-2 text-sm text-gray-600">{user.email}</p>}
<p className="mt-4 text-sm text-gray-600">
<code className="px-1.5 py-0.5 bg-gray-100 rounded">{redirectTo}</code>
</p>
<a href={redirectTo} className="mt-6 block">
<Button className="w-full" size="lg">
{t('auth.login.redirectRetry')}
</Button>
</a>
<Link
href="/"
className="mt-3 inline-block text-sm text-secondary-blue hover:underline"
>
{t('nav.home')}
</Link>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
@@ -212,25 +151,9 @@ function LoginContent() {
</Link>
</div>
<Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{t('auth.login.submit')}
</Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form>
) : magicLinkSent ? (
<div className="text-center py-8">
@@ -2,26 +2,17 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
import Button from '@/components/ui/Button';
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
import PhotoTile from '@/components/gallery/PhotoTile';
import {
GalleryContainer,
GalleryHeroFrame,
MasonryGrid,
} from '@/components/gallery/GalleryLayout';
import { useDownloads } from '@/components/gallery/useDownloads';
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
import Lightbox from '@/components/Lightbox';
import LoginModal from '@/components/LoginModal';
import {
ArrowDownTrayIcon,
CalendarIcon,
CameraIcon,
LinkIcon,
LockClosedIcon,
TicketIcon,
} from '@heroicons/react/24/outline';
@@ -34,13 +25,15 @@ interface GalleryClientProps {
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
}
type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null;
type DeniedState = 'login' | 'ticket' | 'notfound' | null;
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
const { locale } = useLanguage();
const es = locale === 'es';
const { user, isLoading: authLoading } = useAuth();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const shareToken = searchParams.get('token') || undefined;
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
@@ -48,43 +41,13 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
const [loading, setLoading] = useState(!initial);
const [denied, setDenied] = useState<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [loginOpen, setLoginOpen] = useState(false);
const downloads = useDownloads(es);
const downloadLabels = {
download: es ? 'Descargar' : 'Download',
downloading: es ? 'Descargando…' : 'Downloading…',
};
// On a phone a download cannot reach the photo library, so the button
// opens a sheet offering the shareable preview instead (SaveSheet).
// Desktop keeps downloading the original straight away.
const mobileSave = useMobileSave();
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
const downloadPhoto = (photo: Photo) => {
if (mobileSave) {
setSavePhoto({
id: photo.id,
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
previewSize: photo.previewSizeBytes,
originalSize: photo.sizeBytes,
});
return;
}
downloads.start({
id: photo.id,
url: photo.urls.original,
filename: photo.originalFilename,
});
};
// Server-rendered public galleries need no client fetch. Everything else
// (link/ticket/private) is fetched here with the share token and/or the
// viewer's own auth token attached.
useEffect(() => {
if (initial) return;
if (authLoading) return; // wait until the session state has resolved
if (authLoading) return; // wait so the Bearer token is available
let cancelled = false;
setLoading(true);
const fetcher = eventSlug
@@ -99,13 +62,9 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
})
.catch((err: Error) => {
if (cancelled) return;
// The photo-api returns a distinct message per visibility mode so
// each gets its own gate page (see accessDenial in access.go).
const msg = err.message || '';
if (msg.includes('Authentication required')) setDenied('login');
else if (msg.includes('attendees')) setDenied('ticket');
else if (msg.includes('private')) setDenied('private');
else if (msg.includes('share link')) setDenied('link');
else setDenied('notfound');
})
.finally(() => !cancelled && setLoading(false));
@@ -114,123 +73,54 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
};
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
// Mirrors the hero + masonry layout below, so the real page drops straight
// into the placeholder's geometry instead of replacing it.
if (loading || (authLoading && !initial)) {
return <GallerySkeleton count={gallery?.photoCount || undefined} />;
return (
<div className="section-padding">
<div className="container-page">
<ImageGridSkeleton />
</div>
</div>
);
}
// Gate pages for restricted galleries. After a successful login in the
// pop-up, AuthContext's user changes, which re-runs the fetch effect —
// the gate resolves by itself when access is granted.
const loginModal = (
<LoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
message={
es
? 'Inicia sesión para ver esta galería.'
: 'Log in to view this gallery.'
}
/>
);
if (denied === 'login') {
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
return (
<>
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
}
/>
{loginModal}
</>
<GateMessage
icon={LockClosedIcon}
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
body={
es
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
: 'This gallery is for event attendees. Log in with the account you used to book.'
}
action={
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
{es ? 'Iniciar sesión' : 'Log in'}
</Button>
}
/>
);
}
if (denied === 'ticket') {
return (
<>
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
: 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
}
action={
<div className="flex flex-wrap justify-center gap-3">
{(eventSlug || gallery?.event) && (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
)}
<Button onClick={() => setLoginOpen(true)}>
{es ? 'Cambiar de cuenta' : 'Switch account'}
</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'private') {
return (
<>
<GateMessage
icon={LockClosedIcon}
title={es ? 'Esta galería es privada' : 'This gallery is private'}
body={
es
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
: 'Only the organizers can see it. If thats you, log in.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
);
}
if (denied === 'link') {
return (
<>
<GateMessage
icon={LinkIcon}
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
body={
es
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if youre part of the team.'
}
action={
<div className="flex flex-wrap justify-center gap-3">
<Link href="/photos">
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
</Link>
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
</div>
}
/>
{loginModal}
</>
<GateMessage
icon={TicketIcon}
title={es ? 'Solo para asistentes' : 'Attendees only'}
body={
es
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
}
action={
eventSlug || gallery?.event ? (
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
</Link>
) : undefined
}
/>
);
}
@@ -254,14 +144,11 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
}
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
// previewUrl is the download endpoint's preview: rendering and saving from
// the same URL is what lets "Save photo" be answered by the HTTP cache.
const lightboxItems = readyPhotos.map((p) => ({
id: p.id,
previewUrl: p.urls.download || p.urls.preview || p.urls.original,
downloadUrl: p.urls.downloadOriginal || p.urls.original,
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
@@ -284,56 +171,51 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
// first photo. Falls back to a navy gradient with no image.
const coverPhoto =
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
// Same preview URL the lightbox uses, so the cover photo's bytes are
// fetched once for both.
const heroUrl = coverPhoto
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
: null;
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
return (
<div>
{/* Hero — same frame the skeleton renders (GalleryLayout). */}
<GalleryHeroFrame
backdrop={
heroUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
) : null
}
>
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
{/* Hero */}
<div className="relative bg-brand-navy overflow-hidden">
{heroUrl && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
</div>
</div>
</GalleryHeroFrame>
</div>
{/* Masonry grid */}
<GalleryContainer>
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
{readyPhotos.length === 0 ? (
<div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
@@ -342,19 +224,46 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
</p>
</div>
) : (
<MasonryGrid>
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
{readyPhotos.map((photo, i) => (
<PhotoTile
<div
key={photo.id}
photo={photo}
eager={i < 8}
onOpen={() => setLightboxIndex(i)}
onDownload={() => downloadPhoto(photo)}
downloading={downloads.isPending(photo.id)}
labels={downloadLabels}
/>
role="button"
tabIndex={0}
onClick={() => setLightboxIndex(i)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setLightboxIndex(i);
}
}}
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
style={
photo.width && photo.height
? { aspectRatio: `${photo.width} / ${photo.height}` }
: undefined
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.urls.thumb}
alt=""
loading={i < 8 ? 'eager' : 'lazy'}
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<a
href={photo.urls.original}
download={photo.originalFilename || true}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
aria-label={es ? 'Descargar' : 'Download'}
>
<ArrowDownTrayIcon className="w-4 h-4" />
</a>
</div>
))}
</MasonryGrid>
</div>
)}
{lightboxIndex !== null && (
@@ -363,59 +272,8 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
onDownload={(it) => {
const photo = readyPhotos.find((p) => p.id === it.id);
if (photo) downloadPhoto(photo);
}}
downloadingId={
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
}
downloadLabels={downloadLabels}
/>
)}
{/* Shared by both entry points: the tile button and the lightbox's.
Fetches nothing until it is open. */}
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
</GalleryContainer>
{/* Call to action: send attendees to their dashboard, everyone else to
the next event. Auth state comes from the same useAuth() the gate
pages use. */}
<div className="bg-brand-navy">
<div className="container-page px-4 py-12 md:py-16 text-center">
<h2 className="font-heading font-bold text-2xl md:text-3xl text-white">
{user
? es
? '¿Listo para lo que sigue?'
: 'Ready for whats next?'
: es
? '¿Te gustó lo que viste?'
: 'Liked what you saw?'}
</h2>
<p className="mt-2 max-w-xl mx-auto text-white/80 text-sm md:text-base">
{user
? es
? 'Revisa tus entradas y próximos eventos en tu panel.'
: 'Check your tickets and upcoming events from your dashboard.'
: es
? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.'
: 'Join our next event and be part of the next set of photos.'}
</p>
<div className="mt-6">
<Link href={user ? '/dashboard' : '/next'}>
<Button size="lg">
{user
? es
? 'Ir a mi panel'
: 'Go to dashboard'
: es
? 'Únete al próximo evento'
: 'Join the next event'}
</Button>
</Link>
</div>
</div>
</div>
</div>
);
@@ -1,7 +0,0 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the gallery, so the first paint is
// already the gallery's layout rather than an empty page.
export default function Loading() {
return <GallerySkeleton />;
}
+5 -25
View File
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -8,16 +9,13 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
const REDIRECT_TO = '/dashboard';
export default function RegisterPage() {
const router = useRouter();
const { t, locale: language } = useLanguage();
const { register } = useAuth();
const [loading, setLoading] = useState(false);
const [redirecting, setRedirecting] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
@@ -32,12 +30,10 @@ export default function RegisterPage() {
try {
await register(formData);
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
// Deliberately leaves `loading` set: the button must stay disabled until the
// browser replaces this page.
setRedirecting(true);
redirectAfterAuth(REDIRECT_TO);
router.push('/dashboard');
} catch (error: any) {
toast.error(error.message || t('auth.errors.emailExists'));
} finally {
setLoading(false);
}
};
@@ -108,25 +104,9 @@ export default function RegisterPage() {
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<Button
type="submit"
className="w-full"
size="lg"
isLoading={loading || redirecting}
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{t('auth.register.submit')}
</Button>
{redirecting && (
<p
className="text-center text-sm text-gray-600"
role="status"
aria-live="polite"
>
{t('auth.login.redirecting')}
</p>
)}
</form>
<p className="mt-6 text-center text-sm text-gray-600">
+3 -28
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
import { parseDate, formatRucDisplay } from '@/lib/utils';
@@ -8,7 +8,6 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import {
TicketIcon,
CheckCircleIcon,
@@ -52,8 +51,6 @@ export default function AdminBookingsPage() {
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
useEffect(() => {
loadData();
@@ -206,19 +203,6 @@ export default function AdminBookingsPage() {
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// Bookings are paginated client-side: the page already loads every ticket so
// that the stat cards, the group-booking totals and the sibling payment-method
// lookup can see the whole set, and those would break on a server-side slice.
const filterKey = JSON.stringify([selectedEvent, selectedStatus, selectedPaymentStatus, searchQuery]);
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(sortedTickets, page, pageSize, setPage);
const stats = {
total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length,
@@ -424,7 +408,7 @@ export default function AdminBookingsPage() {
</td>
</tr>
) : (
pagedTickets.map((ticket) => {
sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50">
@@ -518,7 +502,7 @@ export default function AdminBookingsPage() {
No bookings found.
</div>
) : (
pagedTickets.map((ticket) => {
sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
const primary = getPrimaryAction(ticket);
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
@@ -596,15 +580,6 @@ export default function AdminBookingsPage() {
)}
</div>
<Pagination
id="bookings"
page={page}
pageSize={pageSize}
total={sortedTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
+8 -2
View File
@@ -107,7 +107,11 @@ export default function AdminEmailsPage() {
const loadEvents = async () => {
try {
const res = await fetch('/api/events', { credentials: 'same-origin' });
const res = await fetch('/api/events', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
setEvents(data.events || []);
@@ -165,7 +169,9 @@ export default function AdminEmailsPage() {
try {
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
credentials: 'same-origin',
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
@@ -1,33 +1,27 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } from '@/lib/api';
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
/**
* Loads the core data for the admin event detail page (event, tickets, active
* email templates, door takings) and exposes a reload function used after
* mutations.
* email templates) and exposes a reload function used after mutations.
*/
export function useEventDetailData(eventId: string) {
const [loading, setLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [doorSummary, setDoorSummary] = useState<DoorSummary | null>(null);
const loadEventData = async () => {
try {
const [eventRes, ticketsRes, templatesRes, doorRes] = await Promise.all([
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(),
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
// night. It is supporting detail, so a failure here must not blank the page.
doorApi.summary(eventId).catch(() => null),
]);
setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
setDoorSummary(doorRes);
} catch (error) {
toast.error('Failed to load event data');
} finally {
@@ -39,5 +33,5 @@ export function useEventDetailData(eventId: string) {
loadEventData();
}, [eventId]);
return { loading, event, tickets, templates, doorSummary, loadEventData };
return { loading, event, tickets, templates, loadEventData };
}
@@ -24,21 +24,18 @@ interface AddTicketModalProps {
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
{ value: 'paid', label: 'Paid' },
{ value: 'door', label: 'At Door' },
{ value: 'unpaid', label: 'Unpaid' },
{ value: 'guest', label: 'Guest' },
];
const SUBMIT_LABELS: Record<AddTicketType, string> = {
paid: 'Create & send ticket',
door: 'Record door payment',
unpaid: 'Create & send pay link',
guest: 'Invite guest',
};
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: EnvelopeIcon,
door: BanknotesIcon,
unpaid: LinkIcon,
guest: StarIcon,
};
@@ -50,15 +47,6 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
if (form.type === 'paid') {
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
lines.push('Confirmation email with QR ticket sent');
} else if (form.type === 'door') {
lines.push(`Cash payment of ${eventPriceLabel} recorded as paid at the door — counts toward revenue`);
lines.push('QR code issued');
if (!form.firstName.trim()) {
lines.push('No name — the ticket is logged as a "Walk-in"');
}
lines.push(hasEmail
? 'Confirmation email with QR ticket sent'
: 'No email — nothing is sent, walk-in kept on the list only');
} else if (form.type === 'unpaid') {
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
lines.push('QR code issued, flagged "unpaid" for door staff');
@@ -78,14 +66,12 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
door: { box: 'bg-emerald-50 border-emerald-200', icon: 'text-emerald-500', text: 'text-emerald-800' },
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
};
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: CheckCircleIcon,
door: BanknotesIcon,
unpaid: BanknotesIcon,
guest: StarIcon,
};
@@ -102,8 +88,6 @@ export function AddTicketModal({
if (!open) return null;
const emailRequired = form.type === 'paid';
// Door walk-ins can be logged with nothing filled in
const nameRequired = form.type !== 'door';
const style = PREVIEW_STYLES[form.type];
const PreviewIcon = PREVIEW_ICONS[form.type];
const SubmitIcon = SUBMIT_ICONS[form.type];
@@ -136,7 +120,7 @@ export function AddTicketModal({
type="button"
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
className={clsx(
'flex-1 px-2 py-2 text-xs sm:text-sm font-medium rounded-btn min-h-[36px] whitespace-nowrap transition-colors',
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
form.type === option.value
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
@@ -149,11 +133,11 @@ export function AddTicketModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name {nameRequired && '*'}</label>
<input type="text" required={nameRequired} value={form.firstName}
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder={nameRequired ? 'First name' : 'First name (optional)'} />
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
@@ -171,7 +155,6 @@ export function AddTicketModal({
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
<p className="text-[10px] text-gray-500 mt-1">
{form.type === 'paid' && 'Ticket will be sent to this email'}
{form.type === 'door' && 'Optional — if provided, the ticket confirmation is sent here'}
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
</p>
@@ -133,16 +133,6 @@ export function EventModals(props: EventModalsProps) {
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div>
</button>
<button
onClick={() => { openAddTicket('door'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Paid at Door</p>
<p className="text-xs text-gray-500">Cash taken at the door, all fields optional</p>
</div>
</button>
<button
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
@@ -1,10 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import clsx from 'clsx';
import {
MagnifyingGlassIcon,
@@ -81,20 +79,6 @@ export function AttendeesTab({
handleMarkPaid,
handleCheckin,
}: AttendeesTabProps) {
// Paginated client-side: the parent already holds every ticket for the event
// so the status counts and the other tabs keep seeing the full set.
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const filterKey = `${searchQuery}|${statusFilter}`;
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(filteredTickets, page, pageSize, setPage);
return (
<div className="space-y-3">
{/* Desktop toolbar */}
@@ -164,9 +148,6 @@ export function AttendeesTab({
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
</DropdownItem>
<DropdownItem onClick={() => { openAddTicket('door'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Paid at Door
</DropdownItem>
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
</DropdownItem>
@@ -253,7 +234,7 @@ export function AttendeesTab({
</td>
</tr>
) : (
pagedTickets.map((ticket) => {
filteredTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50/50">
@@ -339,7 +320,7 @@ export function AttendeesTab({
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
</div>
) : (
pagedTickets.map((ticket) => {
filteredTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<Card key={ticket.id} className="p-3">
@@ -396,16 +377,6 @@ export function AttendeesTab({
)}
</div>
<Pagination
id="attendees"
page={page}
pageSize={pageSize}
total={filteredTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className="mb-20 md:mb-0"
/>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button
@@ -1,4 +1,4 @@
import { PaymentOptionsConfig, DOOR_PAYMENT_METHODS, type DoorPaymentMethod, type DoorSummary } from '@/lib/api';
import { PaymentOptionsConfig } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
@@ -12,85 +12,13 @@ import {
XCircleIcon,
} from '@heroicons/react/24/outline';
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
import { formatCurrency } from '../_utils/format';
interface PaymentsTabProps {
locale: string;
payments: PaymentOverridesController;
/** Takings recorded on the door check-in screen; null while loading or unavailable. */
doorSummary: DoorSummary | null;
}
const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }> = {
cash: { en: 'Cash', es: 'Efectivo' },
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
transfer: { en: 'Transfer', es: 'Transferencia' },
guest: { en: 'Guests', es: 'Invitados' },
};
/**
* End-of-night reconciliation for this event: what staff took on the door, split
* by tender, next to the pre-sale total. Guests are counted, not totalled they
* are free and carry no revenue.
*/
function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary }) {
const es = locale === 'es';
return (
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
<BanknotesIcon className="w-4 h-4 text-emerald-600" />
</div>
<div>
<h4 className="font-semibold text-sm">{es ? 'Ventas en Puerta' : 'Door Sales'}</h4>
<p className="text-[10px] text-gray-500">
{es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'}
</p>
</div>
</div>
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency)}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-3 border-t">
{DOOR_PAYMENT_METHODS.map((method) => {
const entry = summary.door.byMethod[method];
return (
<div key={method} className="bg-gray-50 rounded-lg px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-gray-500">
{es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en}
</p>
<p className="font-bold text-sm leading-tight">
{method === 'guest'
? `${entry.count}`
: formatCurrency(entry.total, summary.currency)}
</p>
{method !== 'guest' && (
<p className="text-[10px] text-gray-500">
{entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')}
</p>
)}
</div>
);
})}
</div>
<div className="flex items-center justify-between text-xs text-gray-600 pt-3 mt-3 border-t">
<span>
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency)}</strong>
{' '}({summary.presale.count})
</span>
<span>
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency)}</strong>
</span>
</div>
</div>
</Card>
);
}
export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) {
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
const {
loadingPayments,
hasPaymentOverrides,
@@ -111,11 +39,6 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
</div>
) : (
<>
{/* Door takings — reconciliation first, configuration below */}
{doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && (
<DoorTakings locale={locale} summary={doorSummary} />
)}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
@@ -1,10 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import {
MagnifyingGlassIcon,
ChevronDownIcon,
@@ -50,20 +48,6 @@ export function TicketsTab({
handleRemoveCheckin,
setShowTicketExportSheet,
}: TicketsTabProps) {
// Paginated client-side, same as the Attendees tab: the parent keeps the full
// ticket list for the header counts and the export actions.
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const filterKey = `${ticketSearchQuery}|${ticketStatusFilter}`;
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(filteredConfirmedTickets, page, pageSize, setPage);
return (
<div className="space-y-3">
{/* Desktop toolbar */}
@@ -168,7 +152,7 @@ export function TicketsTab({
</td>
</tr>
) : (
pagedTickets.map((ticket) => (
filteredConfirmedTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
@@ -232,7 +216,7 @@ export function TicketsTab({
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
</div>
) : (
pagedTickets.map((ticket) => (
filteredConfirmedTickets.map((ticket) => (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
@@ -269,15 +253,6 @@ export function TicketsTab({
))
)}
</div>
<Pagination
id="tickets"
page={page}
pageSize={pageSize}
total={filteredConfirmedTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
);
}
+1 -2
View File
@@ -15,10 +15,9 @@ export interface PrimaryAction {
// Ticket type in the unified Add Ticket modal:
// paid = confirmation + QR emailed, counts toward revenue
// door = already paid in cash at the door, counts toward revenue, every field optional
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
// guest = free comp ticket, auto-confirmed, no revenue
export type AddTicketType = 'paid' | 'door' | 'unpaid' | 'guest';
export type AddTicketType = 'paid' | 'unpaid' | 'guest';
export interface AddTicketFormState {
type: AddTicketType;
+9 -32
View File
@@ -66,7 +66,7 @@ export default function AdminEventDetailPage() {
const eventId = params.id as string;
const { locale } = useLanguage();
const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId);
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId);
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Email state
@@ -84,7 +84,7 @@ export default function AdminEventDetailPage() {
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
@@ -222,7 +222,7 @@ export default function AdminEventDetailPage() {
const res = await ticketsApi.adminAdd({
eventId: event.id,
type: addTicketForm.type,
firstName: addTicketForm.firstName || undefined,
firstName: addTicketForm.firstName,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
@@ -401,14 +401,7 @@ export default function AdminEventDetailPage() {
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
// Door sales can be taken at a custom amount (someone paying for their whole
// group), so once the door summary is loaded it is the authority on the total:
// pre-sale tickets at face value plus whatever was actually taken on the night.
const presaleRevenue = doorSummary
? doorSummary.presale.total
: (paidConfirmedCount + paidCheckedInCount) * event.price;
const doorRevenue = doorSummary?.door.total ?? 0;
const revenue = presaleRevenue + doorRevenue;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
@@ -514,15 +507,7 @@ export default function AdminEventDetailPage() {
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
{
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'bg-gray-50 text-gray-600',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' },
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
@@ -530,7 +515,7 @@ export default function AdminEventDetailPage() {
</div>
<div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
<p className="text-xs text-gray-500">{stat.label}</p>
</div>
</div>
))}
@@ -562,15 +547,7 @@ export default function AdminEventDetailPage() {
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
{
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'text-gray-600 bg-gray-50',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' },
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
@@ -578,7 +555,7 @@ export default function AdminEventDetailPage() {
</div>
<div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
<p className="text-[10px] text-gray-500">{stat.label}</p>
</div>
</div>
))}
@@ -728,7 +705,7 @@ export default function AdminEventDetailPage() {
)}
{activeTab === 'payments' && (
<PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
<PaymentsTab locale={locale} payments={payments} />
)}
</div>
+7 -41
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
@@ -15,16 +15,12 @@ import toast from 'react-hot-toast';
import clsx from 'clsx';
import { parseDate } from '@/lib/utils';
import EventFormModal from './_components/EventFormModal';
import Pagination from '@/components/admin/Pagination';
export default function AdminEventsPage() {
const router = useRouter();
const { t, locale } = useLanguage();
const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
@@ -32,42 +28,22 @@ export default function AdminEventsPage() {
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
useEffect(() => {
loadEvents();
loadFeaturedEvent();
}, []);
useEffect(() => {
loadEvents();
}, [page, pageSize]);
// The ?edit=<id> deep link may point at an event that is not on the current
// page, so fall back to fetching it directly instead of only scanning the page.
const handledEditId = useRef<string | null>(null);
useEffect(() => {
const editId = searchParams.get('edit');
if (!editId || handledEditId.current === editId) return;
const event = events.find(e => e.id === editId);
if (event) {
handledEditId.current = editId;
handleEdit(event);
return;
if (editId && events.length > 0) {
const event = events.find(e => e.id === editId);
if (event) handleEdit(event);
}
if (loading) return;
handledEditId.current = editId;
eventsApi.getById(editId)
.then(({ event }) => handleEdit(event))
.catch(() => toast.error('Event not found'));
}, [searchParams, events, loading]);
}, [searchParams, events]);
const loadEvents = async () => {
try {
const { events, total } = await eventsApi.getAll({ page, pageSize });
const { events } = await eventsApi.getAll();
setEvents(events);
setTotal(total ?? events.length);
// If the current page emptied out (e.g. after deleting its last event),
// fall back to the new last page.
if (events.length === 0 && (total ?? 0) > 0 && page > 1) {
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
}
} catch (error) {
toast.error('Failed to load events');
} finally {
@@ -425,16 +401,6 @@ export default function AdminEventsPage() {
)}
</div>
<Pagination
id="events"
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className="mb-20 md:mb-0"
/>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
+5 -1
View File
@@ -35,7 +35,11 @@ export default function AdminGalleryPage() {
const loadMedia = async () => {
try {
// We need to call the media API - let's add it if it doesn't exist
const res = await fetch('/api/media', { credentials: 'same-origin' });
const res = await fetch('/api/media', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
setMedia(data.media || []);
+6 -28
View File
@@ -2,7 +2,6 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { isManualProvider } from '@/lib/api/payments';
import { parseDate, formatRucDisplay } from '@/lib/utils';
@@ -23,7 +22,6 @@ import {
BoltIcon,
BanknotesIcon,
BuildingLibraryIcon,
CalendarDaysIcon,
CreditCardIcon,
EnvelopeIcon,
FunnelIcon,
@@ -37,7 +35,6 @@ type Tab = 'pending_approval' | 'all';
export default function AdminPaymentsPage() {
const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
// Manual-gateway payments still in bare 'pending': the customer may have paid
@@ -537,29 +534,12 @@ export default function AdminPaymentsPage() {
</div>
)}
{/* Always shown for payments the customer never confirmed, this
is the only timestamp there is. */}
{(() => {
const age = getAgeInfo(selectedPayment.createdAt);
return (
<div className="flex items-center gap-2 text-sm text-gray-600">
<CalendarDaysIcon className="w-4 h-4" />
{locale === 'es' ? 'Reserva realizada:' : 'Booking made:'} {formatDate(selectedPayment.createdAt)}
{age && <span className="text-gray-400">({age.label})</span>}
</div>
);
})()}
{selectedPayment.userMarkedPaidAt && (() => {
const age = getAgeInfo(selectedPayment.userMarkedPaidAt);
return (
<div className="flex items-center gap-2 text-sm text-gray-600">
<ClockIcon className="w-4 h-4" />
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
{age && <span className="text-gray-400">({age.label})</span>}
</div>
);
})()}
{selectedPayment.userMarkedPaidAt && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<ClockIcon className="w-4 h-4" />
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
</div>
)}
{selectedPayment.reminderSentAt && (
<div className="flex items-center gap-2 text-sm text-amber-600">
@@ -780,7 +760,6 @@ export default function AdminPaymentsPage() {
)}
{/* Summary Cards */}
{!privacyMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<Card className="p-4">
<div className="flex items-center gap-3">
@@ -849,7 +828,6 @@ export default function AdminPaymentsPage() {
</div>
</Card>
</div>
)}
{/* Tabs */}
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
+4 -28
View File
@@ -18,7 +18,6 @@ import {
CheckCircleIcon,
ChevronDownIcon,
ChevronUpIcon,
DocumentDuplicateIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
LinkIcon,
@@ -35,9 +34,7 @@ interface UploadItem {
name: string;
sizeBytes: number;
progress: number; // 0..1 while uploading
// 'duplicate' is terminal: the gallery already held these bytes, so nothing
// was stored and no new tile appears in the grid.
status: 'queued' | 'uploading' | 'processing' | 'error' | 'duplicate';
status: 'queued' | 'uploading' | 'processing' | 'error';
error?: string;
photoId?: string;
}
@@ -120,19 +117,8 @@ export default function AdminGalleryDetailPage() {
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
patchUpload(key, { progress: fraction })
);
// A duplicate echoes back a photo already in the grid, so merge by id
// instead of appending (`duplicate` is an upload outcome, not photo
// state, so it is dropped here).
setPhotos((prev) => {
const byId = new Map(prev.map((p) => [p.id, p]));
added.forEach(({ duplicate: _duplicate, ...photo }) => byId.set(photo.id, photo));
return Array.from(byId.values());
});
patchUpload(key, {
status: added[0]?.duplicate ? 'duplicate' : 'processing',
progress: 1,
photoId: added[0]?.id,
});
setPhotos((prev) => [...prev, ...added]);
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id });
} catch (err) {
patchUpload(key, {
status: 'error',
@@ -162,8 +148,7 @@ export default function AdminGalleryDetailPage() {
};
// A row is "done" once its photo finished processing; the panel derives
// this from the photos list instead of tracking it separately. 'duplicate'
// is terminal and never reconciled — its photo was already there.
// this from the photos list instead of tracking it separately.
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
if (u.status === 'processing' && u.photoId) {
const photo = photos.find((p) => p.id === u.photoId);
@@ -281,7 +266,6 @@ export default function AdminGalleryDetailPage() {
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
const openLightboxFor = (photoId: string) => {
const idx = readyPhotos.findIndex((p) => p.id === photoId);
@@ -594,11 +578,6 @@ export default function AdminGalleryDetailPage() {
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
{ds.state === 'done' && formatBytes(u.sizeBytes)}
{ds.state === 'duplicate' && (
<span className="text-amber-600">
{es ? 'Ya está en esta galería' : 'Already in this gallery'}
</span>
)}
{ds.state === 'error' && (
<span className="text-red-600" title={ds.error}>
{ds.error}
@@ -608,9 +587,6 @@ export default function AdminGalleryDetailPage() {
</div>
<span className="shrink-0">
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
{ds.state === 'duplicate' && (
<DocumentDuplicateIcon className="w-6 h-6 text-amber-600" />
)}
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
{ds.state === 'processing' && (
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
@@ -1,159 +0,0 @@
'use client';
import clsx from 'clsx';
import {
CheckCircleIcon,
ArrowUturnLeftIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { PaymentButtons } from './PaymentButtons';
function checkinTime(checkinAt: string | null): string {
if (!checkinAt) return '';
return parseDate(checkinAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'cash',
bitcoin: 'bitcoin',
transfer: 'transfer',
guest: 'guest',
};
/** The second line of a row: everything staff needs to decide in one glance. */
function statusLine(attendee: DoorAttendee, currency: string): string {
if (attendee.status === 'cancelled') return 'Cancelled';
if (attendee.checkedIn) {
const time = checkinTime(attendee.checkinAt);
const how = attendee.doorMethod ? ` · paid ${METHOD_LABELS[attendee.doorMethod]}` : '';
return time ? `Checked in ${time}${how}` : `Checked in${how}`;
}
const parts: string[] = [];
if (attendee.paymentStatus === 'comp') parts.push('Guest');
else if (attendee.paymentStatus === 'paid') parts.push('Paid');
else parts.push(`Unpaid · ${formatCurrency(attendee.amountDue, currency)} due`);
if (attendee.isGroupBooking) parts.push('group booking');
if (attendee.status === 'pending') parts.push('pending');
return parts.join(' · ');
}
export function AttendeeRow({
attendee,
currency,
price,
expanded,
flashing,
busy,
onTap,
onPay,
}: {
attendee: DoorAttendee;
currency: string;
price: number;
expanded: boolean;
flashing: boolean;
busy: boolean;
onTap: () => void;
onPay: (method: DoorPaymentMethod, amount: number) => void;
}) {
const isCancelled = attendee.status === 'cancelled';
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
// A settled, not-yet-arrived attendee is the one-tap case: the whole row checks
// them in. Everyone else opens the tenders inline instead.
const isOneTap = !isCancelled && !attendee.checkedIn && settled;
return (
<div
className={clsx(
'rounded-2xl border transition-colors',
flashing
? 'bg-emerald-600 border-emerald-400'
: attendee.checkedIn || isCancelled
? 'bg-gray-900 border-gray-800'
: 'bg-gray-800 border-gray-700',
)}
>
<button
onClick={onTap}
disabled={busy}
className="w-full text-left px-4 py-3 min-h-[64px] flex items-center gap-3 active:scale-[0.99] transition-transform disabled:opacity-60"
>
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-bold text-lg truncate',
flashing ? 'text-white' : attendee.checkedIn || isCancelled ? 'text-gray-400' : 'text-white',
)}
>
{attendee.fullName}
</p>
<p
className={clsx(
'text-sm truncate flex items-center gap-1.5',
flashing
? 'text-emerald-50'
: isCancelled
? 'text-red-400'
: attendee.checkedIn
? 'text-gray-500'
: attendee.paymentStatus === 'unpaid'
? 'text-amber-400'
: 'text-gray-400',
)}
>
{attendee.isGroupBooking && !attendee.checkedIn && <UserGroupIcon className="w-4 h-4 flex-shrink-0" />}
{statusLine(attendee, currency)}
</p>
</div>
{flashing ? (
<CheckCircleIcon className="w-8 h-8 text-white flex-shrink-0" />
) : attendee.checkedIn ? (
<CheckCircleIcon className="w-7 h-7 text-emerald-500/60 flex-shrink-0" />
) : isCancelled ? (
<span className="flex-shrink-0 text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded-full bg-red-950 text-red-400">
Cancelled
</span>
) : isOneTap ? (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-primary-yellow">
Check in
</span>
) : (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-amber-400">
Collect
</span>
)}
</button>
{expanded && !attendee.checkedIn && (
<div className="px-3 pb-3 pt-1 space-y-2">
{isCancelled && (
<p className="text-xs text-gray-400 px-1 flex items-center gap-1.5">
<ArrowUturnLeftIcon className="w-4 h-4" />
Reactivate as a walk-in pick how they are paying.
</p>
)}
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
</div>
)}
{expanded && attendee.checkedIn && (
<div className="px-4 pb-3 -mt-1">
<p className="text-sm text-gray-400">
Already checked in
{attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''}
{attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}.
</p>
</div>
)}
</div>
);
}
@@ -1,185 +0,0 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import {
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
GiftIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
// The four tenders staff can take at the door. One tap settles and checks in;
// long-press (or the chevron) opens multiples for someone paying for their group.
const TENDERS: {
method: DoorPaymentMethod;
label: string;
icon: typeof BanknotesIcon;
className: string;
}[] = [
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
];
const LONG_PRESS_MS = 450;
export function PaymentButtons({
price,
currency,
onPay,
disabled,
}: {
price: number;
currency: string;
onPay: (method: DoorPaymentMethod, amount: number) => void;
disabled?: boolean;
}) {
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customValue, setCustomValue] = useState('');
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const [longPressed, setLongPressed] = useState(false);
const openAmounts = (method: DoorPaymentMethod) => {
if (method === 'guest') return;
setAmountsFor(method);
setCustomOpen(false);
setCustomValue('');
};
const startPress = (method: DoorPaymentMethod) => {
setLongPressed(false);
const timer = setTimeout(() => {
setLongPressed(true);
openAmounts(method);
}, LONG_PRESS_MS);
setPressTimer(timer);
};
const endPress = (method: DoorPaymentMethod) => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
// A long press already opened the multiples; don't also charge 1x on release.
if (longPressed) {
setLongPressed(false);
return;
}
if (disabled) return;
onPay(method, method === 'guest' ? 0 : price);
};
const cancelPress = () => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
setLongPressed(false);
};
if (amountsFor) {
const tender = TENDERS.find((t) => t.method === amountsFor)!;
return (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<p className="text-sm font-semibold text-white">{tender.label} how many?</p>
<button
onClick={() => { setAmountsFor(null); setCustomOpen(false); }}
className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white"
>
Back
</button>
</div>
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3].map((qty) => (
<button
key={qty}
disabled={disabled}
onClick={() => onPay(tender.method, price * qty)}
className={clsx(
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
{qty}x
<span className="text-[10px] font-medium opacity-80">
{formatCurrency(price * qty, currency)}
</span>
</button>
))}
<button
disabled={disabled}
onClick={() => setCustomOpen((open) => !open)}
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
>
Custom
</button>
</div>
{customOpen && (
<div className="flex gap-2">
<input
type="number"
inputMode="numeric"
autoFocus
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={`Amount in ${currency}`}
className="flex-1 min-h-[48px] px-4 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
disabled={disabled || !customValue || Number(customValue) < 0}
onClick={() => onPay(tender.method, Number(customValue))}
className={clsx(
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
Take
</button>
</div>
)}
</div>
);
}
return (
<div className="grid grid-cols-4 gap-2">
{TENDERS.map((tender) => (
<button
key={tender.method}
disabled={disabled}
onPointerDown={() => startPress(tender.method)}
onPointerUp={() => endPress(tender.method)}
onPointerLeave={cancelPress}
onPointerCancel={cancelPress}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
'relative min-h-[64px] rounded-2xl text-white font-bold flex flex-col items-center justify-center gap-1 select-none disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
<tender.icon className="w-6 h-6" />
<span className="text-xs">{tender.label}</span>
{tender.method !== 'guest' && (
// Visible affordance for the same thing long-press does: staff who
// never discover the hold still find the multiples.
<span
role="button"
aria-label={`${tender.label} quick amounts`}
onPointerDown={(e) => { e.stopPropagation(); cancelPress(); }}
onPointerUp={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); openAmounts(tender.method); }}
className="absolute top-0.5 right-0.5 w-7 h-7 flex items-center justify-center rounded-full text-white/70"
>
<ChevronDownIcon className="w-4 h-4" />
</span>
)}
</button>
))}
</div>
);
}
@@ -1,179 +0,0 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { QrCodeIcon, XMarkIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
// The camera is a fullscreen overlay opened from the search row, not a tab. It
// only exists while a scan is happening, so it never holds the camera (or the
// screen) while staff are typing a name.
/** Release any camera stream html5-qrcode left attached to a <video> element. */
function stopAllTracks() {
try {
document.querySelectorAll('video').forEach((video) => {
const stream = video.srcObject as MediaStream | null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
video.srcObject = null;
}
});
} catch {}
}
export function QRScannerOverlay({
onScan,
onClose,
}: {
onScan: (code: string) => void;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const scannerRef = useRef<any>(null);
const mountedRef = useRef(true);
const elementId = useRef(`qr-scanner-${Date.now()}`);
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
const [ready, setReady] = useState(false);
const destroyScanner = useCallback(async () => {
if (scannerRef.current) {
try { await scannerRef.current.stop(); } catch {}
try { scannerRef.current.clear(); } catch {}
scannerRef.current = null;
}
stopAllTracks();
}, []);
useEffect(() => {
mountedRef.current = true;
let cancelled = false;
const init = async () => {
const container = containerRef.current;
if (!container) return;
const id = elementId.current;
container.innerHTML = '';
const div = document.createElement('div');
div.id = id;
div.style.width = '100%';
div.style.height = '100%';
container.appendChild(div);
try {
const { Html5Qrcode } = await import('html5-qrcode');
if (cancelled) return;
const scanner = new Html5Qrcode(id);
scannerRef.current = scanner;
await scanner.start(
{ facingMode },
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
(decodedText: string) => {
if (mountedRef.current) onScan(decodedText);
},
() => {}
);
if (cancelled) {
await destroyScanner();
return;
}
// Force a layout pass: some browsers leave the video mis-sized until reflow.
requestAnimationFrame(() => {
if (container) {
container.style.display = 'none';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
container.offsetHeight;
container.style.display = '';
}
if (mountedRef.current) setReady(true);
});
} catch (error) {
console.error('Scanner error:', error);
if (!cancelled && mountedRef.current) {
toast.error('Failed to start camera. Check permissions.');
onClose();
}
}
};
init();
return () => {
cancelled = true;
mountedRef.current = false;
destroyScanner();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [facingMode]);
// Backgrounding the browser suspends the camera; drop it and rebuild on return.
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
destroyScanner();
} else if (document.visibilityState === 'visible' && mountedRef.current) {
setFacingMode((prev) => {
const temp = prev === 'environment' ? 'user' : 'environment';
setTimeout(() => {
if (mountedRef.current) setFacingMode(prev);
}, 100);
return temp;
});
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [destroyScanner]);
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 safe-area-top">
<p className="text-white font-semibold">Scan ticket</p>
<div className="flex items-center gap-2">
{ready && (
<button
onClick={() => setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'))}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Switch camera"
>
<VideoCameraIcon className="w-6 h-6" />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Close scanner"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
/>
{!ready && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
<p className="text-sm opacity-60">Starting camera...</p>
</div>
</div>
)}
</div>
<div className="flex-shrink-0 px-6 py-5 pb-safe">
<p className="text-center text-gray-400 text-sm">
Point at the ticket QR it checks in and closes automatically.
</p>
</div>
</div>
);
}
@@ -1,240 +0,0 @@
'use client';
import clsx from 'clsx';
import {
XMarkIcon,
ClockIcon,
QrCodeIcon,
MagnifyingGlassIcon,
UserPlusIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod, DoorSummary } from '@/lib/api';
import { DOOR_PAYMENT_METHODS } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
export interface SessionEntry {
idempotencyKey: string;
ticketId: string;
name: string;
at: string;
entry: 'scan' | 'search' | 'walkin';
method: DoorPaymentMethod | null;
amount: number;
undone: boolean;
failed: boolean;
}
const ENTRY_ICONS = {
scan: QrCodeIcon,
search: MagnifyingGlassIcon,
walkin: UserPlusIcon,
};
const ENTRY_LABELS = {
scan: 'Scanned',
search: 'Search',
walkin: 'Walk-in',
};
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'Cash',
bitcoin: 'Bitcoin',
transfer: 'Transfer',
guest: 'Guest',
};
/** Totals for the current shift, computed from this session's own entries. */
function sessionTotals(entries: SessionEntry[]) {
const totals: Record<DoorPaymentMethod, { count: number; total: number }> = {
cash: { count: 0, total: 0 },
bitcoin: { count: 0, total: 0 },
transfer: { count: 0, total: 0 },
guest: { count: 0, total: 0 },
};
let grand = 0;
for (const entry of entries) {
if (entry.undone || entry.failed || !entry.method) continue;
totals[entry.method].count += 1;
totals[entry.method].total += entry.amount;
grand += entry.amount;
}
return { totals, grand };
}
function CashUpGrid({
totals,
currency,
}: {
totals: Record<DoorPaymentMethod, { count: number; total: number }>;
currency: string;
}) {
return (
<div className="grid grid-cols-2 gap-2">
{DOOR_PAYMENT_METHODS.map((method) => (
<div key={method} className="bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<p className="text-[11px] uppercase tracking-wide text-gray-500">{METHOD_LABELS[method]}</p>
<p className="font-bold text-white text-base leading-tight">
{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}
</p>
{method !== 'guest' && (
<p className="text-[11px] text-gray-500">
{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}
</p>
)}
</div>
))}
</div>
);
}
/**
* The end-of-night view: what this shift took, what the whole event day took,
* and the feed of who came in and how.
*/
export function SessionSheet({
entries,
summary,
summaryLoading,
showEventTotals,
currency,
onRefresh,
onClose,
}: {
entries: SessionEntry[];
summary: DoorSummary | null;
summaryLoading: boolean;
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
showEventTotals: boolean;
currency: string;
onRefresh: () => void;
onClose: () => void;
}) {
const { totals, grand } = sessionTotals(entries);
const liveEntries = entries.filter((e) => !e.undone);
return (
<div className="fixed inset-0 z-50 bg-gray-950 flex flex-col" style={{ height: '100dvh' }}>
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top flex items-center justify-between">
<div>
<p className="font-bold text-white text-lg">Session</p>
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div>
<div className="flex items-center gap-2">
{showEventTotals && (
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Close session view"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5 pb-safe">
{/* This shift */}
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">This session</h2>
<p className="text-primary-yellow font-bold">{formatCurrency(grand, currency)}</p>
</div>
<CashUpGrid totals={totals} currency={currency} />
</section>
{/* Whole event, from the server the number to reconcile the cash box
against. Admin/organizer only; the API enforces the same split. */}
{showEventTotals && (
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
<p className="text-primary-yellow font-bold">
{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}
</p>
</div>
{summary ? (
<>
<CashUpGrid totals={summary.door.byMethod} currency={summary.currency} />
<div className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<div>
<p className="text-[11px] uppercase tracking-wide text-gray-500">Pre-sale</p>
<p className="font-bold text-white">
{formatCurrency(summary.presale.total, summary.currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Event total</p>
<p className="font-bold text-white">{formatCurrency(summary.total, summary.currency)}</p>
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500">
{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}
</p>
)}
</section>
)}
{/* Feed */}
<section className="space-y-2">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Recent check-ins</h2>
{entries.length === 0 ? (
<div className="text-center text-gray-500 py-10">
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No check-ins yet</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const Icon = ENTRY_ICONS[entry.entry];
return (
<div
key={entry.idempotencyKey}
className={clsx(
'rounded-xl border px-3 py-2.5 flex items-center gap-3',
entry.failed
? 'bg-red-950/40 border-red-900'
: entry.undone
? 'bg-gray-900 border-gray-800 opacity-50'
: 'bg-gray-800 border-gray-700',
)}
>
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-medium truncate',
entry.undone ? 'text-gray-500 line-through' : 'text-white',
)}
>
{entry.name}
</p>
<p className="text-xs text-gray-500 truncate">
{ENTRY_LABELS[entry.entry]}
{entry.method ? ` · ${METHOD_LABELS[entry.method]}` : ''}
{entry.method && entry.method !== 'guest'
? ` ${formatCurrency(entry.amount, currency)}`
: ''}
{entry.failed ? ' · failed' : entry.undone ? ' · undone' : ''}
</p>
</div>
<p className="text-sm text-gray-400 flex-shrink-0">{entry.at}</p>
</div>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
@@ -1,157 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import type { DoorPaymentMethod } from '@/lib/api';
import { PaymentButtons } from './PaymentButtons';
export interface WalkInDraft {
firstName: string;
lastName: string;
phone: string;
email: string;
ruc: string;
}
export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
firstName,
lastName: '',
phone: '',
email: '',
ruc: '',
});
/**
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
* name and four tenders. Email, phone and RUC live behind "Add details" so the
* rare person who wants a receipt never slows down the queue behind them.
*/
export function WalkInRow({
typedText,
expanded,
draft,
price,
currency,
busy,
onExpand,
onChange,
onPay,
onCancel,
}: {
typedText: string;
expanded: boolean;
draft: WalkInDraft;
price: number;
currency: string;
busy: boolean;
onExpand: () => void;
onChange: (draft: WalkInDraft) => void;
onPay: (method: DoorPaymentMethod, amount: number) => void;
onCancel: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const firstNameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (expanded) firstNameRef.current?.focus();
}, [expanded]);
useEffect(() => {
if (!expanded) setDetailsOpen(false);
}, [expanded]);
if (!expanded) {
return (
<button
onClick={onExpand}
className="w-full min-h-[64px] px-4 py-3 rounded-2xl border-2 border-dashed border-primary-yellow/50 bg-primary-yellow/5 text-left flex items-center gap-3 active:scale-[0.99] transition-transform"
>
<UserPlusIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<span className="font-bold text-primary-yellow truncate">
{typedText ? `Add "${typedText}" as walk-in` : 'Add a walk-in'}
</span>
</button>
);
}
const field = (key: keyof WalkInDraft, value: string) => onChange({ ...draft, [key]: value });
return (
<div className="rounded-2xl border-2 border-primary-yellow/50 bg-gray-800 p-3 space-y-2">
<div className="flex items-center justify-between px-1">
<p className="font-bold text-primary-yellow">New walk-in</p>
<button onClick={onCancel} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
Cancel
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
ref={firstNameRef}
value={draft.firstName}
onChange={(e) => field('firstName', e.target.value)}
placeholder="First name"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.lastName}
onChange={(e) => field('lastName', e.target.value)}
placeholder="Last name (optional)"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<input
value={draft.phone}
onChange={(e) => field('phone', e.target.value)}
placeholder="Phone (optional)"
inputMode="tel"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
onClick={() => setDetailsOpen((open) => !open)}
className="w-full min-h-[48px] flex items-center justify-between px-2 text-sm text-gray-400 active:text-white"
>
Add details (email, RUC)
<ChevronDownIcon className={clsx('w-4 h-4 transition-transform', detailsOpen && 'rotate-180')} />
</button>
{detailsOpen && (
<div className="space-y-2">
<input
value={draft.email}
onChange={(e) => field('email', e.target.value)}
placeholder="Email — sends the usual confirmation"
inputMode="email"
autoComplete="off"
autoCapitalize="none"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.ruc}
onChange={(e) => field('ruc', e.target.value)}
placeholder="RUC (for factura)"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
)}
<PaymentButtons
price={price}
currency={currency}
onPay={onPay}
disabled={busy || !draft.firstName.trim()}
/>
</div>
);
}
@@ -1,56 +0,0 @@
// Firing door actions without ever blocking the queue of people at the door.
//
// The UI flashes green and clears the input the moment staff taps; the write
// happens here, in the background, with retries. Venue wifi drops constantly, so
// every action carries an idempotency key: a retry that actually succeeded the
// first time returns the original result instead of double-charging anyone.
import { doorApi, type DoorCheckinRequest, type DoorCheckinResponse } from '@/lib/api';
/** UUID per action. crypto.randomUUID needs a secure context; fall back when absent. */
export function newIdempotencyKey(): string {
const cryptoRef = typeof crypto !== 'undefined' ? crypto : undefined;
if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
return `door-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
// Roughly 5 seconds of retrying in total — long enough to ride out a wifi blip,
// short enough that staff learn about a real failure while the person is still
// in front of them.
const RETRY_DELAYS_MS = [400, 1200, 3000];
/**
* Errors worth retrying are the ones a retry can fix: network failures, gateway
* errors, rate limits. A 400 "ticket belongs to a different event" will fail
* identically forever, so it surfaces immediately.
*/
function isRetryable(error: any): boolean {
const status = error?.status;
if (typeof status === 'number') return status >= 500 || status === 408 || status === 429;
// No status at all means the request never reached the server (fetch rejects
// with a TypeError when the connection drops) — exactly the case to retry.
return true;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function submitDoorAction(
eventId: string,
body: DoorCheckinRequest,
): Promise<DoorCheckinResponse> {
let lastError: any;
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
try {
return await doorApi.checkin(eventId, body);
} catch (error: any) {
lastError = error;
if (attempt === RETRY_DELAYS_MS.length || !isRetryable(error)) break;
await sleep(RETRY_DELAYS_MS[attempt]);
}
}
throw lastError;
}
export async function undoDoorAction(eventId: string, idempotencyKey: string): Promise<void> {
await doorApi.undo(eventId, idempotencyKey);
}
@@ -1,41 +0,0 @@
// Haptic + audio confirmation. At a loud, dark door the sound and the buzz are
// what staff actually register — the green flash is confirmation for the person
// standing in front of them.
export function playSuccessSound() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.3;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
osc.stop(ctx.currentTime + 0.15);
} catch {}
}
export function playErrorSound() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 300;
osc.type = 'square';
gain.gain.value = 0.2;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.stop(ctx.currentTime + 0.3);
} catch {}
}
export function vibrate(pattern: number | number[]) {
try {
if (navigator.vibrate) navigator.vibrate(pattern);
} catch {}
}
@@ -1,170 +0,0 @@
// Door search: runs entirely in memory over the preloaded attendee list, so
// typing never touches the network.
//
// The rules exist because of who is standing at the door. People say "Jose" for
// José and "Nunez" for Núñez, so both sides are stripped of diacritics. They give
// a surname first as often as a first name, so every word is matched
// independently. Two people called María are told apart by the last digits of a
// phone number, so a mostly-numeric query searches phone digits instead of names.
import type { DoorAttendee } from '@/lib/api';
/** Lowercase and strip combining marks, so "José" and "jose" are the same string. */
export function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
export function digitsOnly(value: string): string {
return value.replace(/\D/g, '');
}
/** Precomputed per attendee once per list load; recomputing per keystroke is what makes search feel slow. */
export interface DoorSearchIndex {
words: string[];
full: string;
phoneDigits: string;
email: string;
}
export function buildIndex(attendee: DoorAttendee): DoorSearchIndex {
const first = normalize(attendee.firstName || '');
const last = normalize(attendee.lastName || '');
const full = `${first} ${last}`.trim();
return {
words: full.split(/\s+/).filter(Boolean),
full,
phoneDigits: digitsOnly(attendee.phone || ''),
email: normalize(attendee.email || ''),
};
}
/**
* A query is treated as a phone lookup when it is mostly digits staff asking
* "what are the last four of your number?" type exactly that and nothing else.
*/
export function isPhoneQuery(query: string): boolean {
const compact = query.replace(/\s/g, '');
if (compact.length < 3) return false;
const digits = digitsOnly(compact);
return digits.length >= 3 && digits.length / compact.length >= 0.6;
}
/**
* Bounded Damerau-Levenshtein: true when one insert, delete, substitution or
* swap of adjacent letters apart. The swap matters "Jhon" for John is the
* single commonest way a name gets mistyped, and plain Levenshtein scores it 2.
*/
function withinOneEdit(a: string, b: string): boolean {
const la = a.length;
const lb = b.length;
if (Math.abs(la - lb) > 1) return false;
let i = 0;
let j = 0;
let edits = 0;
while (i < la && j < lb) {
if (a[i] === b[j]) {
i++;
j++;
continue;
}
if (++edits > 1) return false;
if (la > lb) i++;
else if (lb > la) j++;
else if (a[i + 1] === b[j] && a[i] === b[j + 1]) {
// Adjacent letters swapped: consume both and count it as the one edit.
i += 2;
j += 2;
} else {
i++;
j++;
}
}
return edits + (la - i) + (lb - j) <= 1;
}
// Lower tier ranks first.
const TIER_PREFIX = 0;
const TIER_SUBSTRING = 1;
const TIER_FUZZY = 2;
/** Best match tier for one attendee, or null when the query does not match at all. */
export function matchTier(index: DoorSearchIndex, query: string, phoneMode: boolean): number | null {
if (phoneMode) {
const digits = digitsOnly(query);
if (!index.phoneDigits || !digits) return null;
// Substring, so a query of the last four digits matches +595 981 234 567.
return index.phoneDigits.includes(digits) ? TIER_PREFIX : null;
}
if (index.full.startsWith(query)) return TIER_PREFIX;
if (index.words.some((word) => word.startsWith(query))) return TIER_PREFIX;
if (index.full.includes(query)) return TIER_SUBSTRING;
// Email is a fallback, not a way staff normally searches, and a one- or
// two-letter query would match almost every address — so it needs 3 characters.
if (query.length >= 3 && index.email && index.email.includes(query)) return TIER_SUBSTRING;
// Typo tolerance is the last resort: "jhon" still finds John, but only after
// every real prefix and substring match has been listed.
if (query.length >= 4 && index.words.some((word) => withinOneEdit(word, query))) return TIER_FUZZY;
return null;
}
/** Attendees who are still waiting to come in, shown when the input is empty. */
const OPEN_STATUSES = new Set(['confirmed', 'pending', 'on_hold']);
export interface IndexedAttendee {
attendee: DoorAttendee;
index: DoorSearchIndex;
}
/**
* Sort weight for equal match quality: people still to come in first, then those
* already inside, then cancelled tickets last a cancelled row must never sit
* above a valid one that matches just as well.
*/
function stateWeight(attendee: DoorAttendee): number {
if (attendee.status === 'cancelled') return 2;
return attendee.checkedIn ? 1 : 0;
}
function byRank(
a: { attendee: DoorAttendee; tier: number },
b: { attendee: DoorAttendee; tier: number },
): number {
if (a.tier !== b.tier) return a.tier - b.tier;
const stateDiff = stateWeight(a.attendee) - stateWeight(b.attendee);
if (stateDiff !== 0) return stateDiff;
return a.attendee.fullName.localeCompare(b.attendee.fullName, undefined, { sensitivity: 'base' });
}
export function searchAttendees(indexed: IndexedAttendee[], rawQuery: string): DoorAttendee[] {
const query = normalize(rawQuery);
// Empty input is the small-event case: everyone still to come in, alphabetical,
// so staff can scroll and tap without typing anything at all.
if (!query) {
return indexed
.filter(({ attendee }) => OPEN_STATUSES.has(attendee.status) && !attendee.checkedIn)
.map(({ attendee }) => attendee)
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
}
const phoneMode = isPhoneQuery(rawQuery);
const scored: { attendee: DoorAttendee; tier: number }[] = [];
for (const entry of indexed) {
const tier = matchTier(entry.index, query, phoneMode);
if (tier !== null) scored.push({ attendee: entry.attendee, tier });
}
return scored.sort(byRank).map((s) => s.attendee);
}
/** True when the typed text already names somebody exactly — no walk-in row needed. */
export function hasExactMatch(results: DoorAttendee[], rawQuery: string): boolean {
const query = normalize(rawQuery);
if (!query) return true;
return results.some((a) => normalize(a.fullName) === query || normalize(a.firstName) === query);
}
File diff suppressed because it is too large Load Diff
+73 -10
View File
@@ -9,13 +9,26 @@ import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import Pagination from '@/components/admin/Pagination';
type RegisteredRange = '' | '7d' | '30d' | '90d';
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: (number | '...')[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) pages.push('...');
pages.push(totalPages);
return pages;
}
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
if (!range) return undefined;
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
@@ -405,14 +418,64 @@ export default function AdminUsersPage() {
)}
</div>
<Pagination
id="users"
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
{/* Pagination */}
{total > 0 && (
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label>
<select
id="users-page-size"
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="text-xs text-gray-500 whitespace-nowrap">
{(page - 1) * pageSize + 1}&ndash;{Math.min(page * pageSize, total)} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">&hellip;</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={clsx(
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
p === page
? 'bg-primary-yellow text-primary-dark font-semibold'
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
)
)}
<button
onClick={() => setPage(page + 1)}
disabled={page >= Math.ceil(total / pageSize)}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
+5 -23
View File
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useLanguage } from '@/context/LanguageContext';
import { safeInternalPath } from '@/lib/safeRedirect';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
declare global {
@@ -84,29 +83,12 @@ export default function GoogleSignInButton({
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
onSuccess?.();
// Full page load for a clean state, recording the attempt so the login page can
// tell a bounced redirect from a fresh visit. Constrained to a same-origin path
// to avoid open redirects.
redirectAfterAuth(safeInternalPath(redirectTo, '/dashboard'));
// Use window.location for navigation to ensure clean state.
// Constrain to a same-origin path to avoid open redirects.
window.location.href = safeInternalPath(redirectTo, '/dashboard');
} catch (error: unknown) {
// better-auth returns OAUTH_LINK_ERROR (message: "account not linked")
// when it refuses to attach the Google identity to the existing user
// row for that address. The backend now links unverified local rows
// (see lib/betterAuth.ts accountLinking), so this should be
// unreachable — but a bare "account not linked" toast is a dead end,
// so keep an actionable fallback rather than a generic one.
const isLinkError = (error as { code?: string } | null)?.code === 'OAUTH_LINK_ERROR';
const errorMessage = isLinkError
? 'This email is already registered. Sign in with your password, or use the "Email Link" option on the login page.'
: error instanceof Error
? error.message
: 'Google login failed';
const displayError =
locale === 'es'
? isLinkError
? 'Este correo ya esta registrado. Inicia sesion con tu contrasena o usa la opcion "Enlace por correo".'
: 'Error al iniciar sesion con Google'
: errorMessage;
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
onError?.(displayError);
toast.error(displayError);
} finally {
+53 -154
View File
@@ -1,21 +1,18 @@
'use client';
import { useEffect, useCallback, useRef, useState } from 'react';
import { useEffect, useCallback, useState } from 'react';
import {
XMarkIcon,
ChevronLeftIcon,
ChevronRightIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
import Spinner from '@/components/ui/Spinner';
export interface LightboxItem {
id: string;
previewUrl: string;
downloadUrl: string;
filename?: string;
/** Small thumbnail for the filmstrip; falls back to previewUrl. */
thumbUrl?: string;
}
interface LightboxProps {
@@ -25,35 +22,15 @@ interface LightboxProps {
onNavigate: (index: number) => void;
/** Extra per-item action buttons rendered in the top bar (admin use). */
renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
/**
* Handles the download in JS instead of navigating, so the button can show
* progress. Without it the button stays a plain <a download>.
*/
onDownload?: (item: LightboxItem) => void;
/** Id of the item currently downloading (pairs with onDownload). */
downloadingId?: string | null;
downloadLabels?: { download: string; downloading: string };
}
/**
* Full-screen photo lightbox with keyboard and swipe navigation, in the
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
*/
export default function Lightbox({
items,
index,
onClose,
onNavigate,
renderActions,
onDownload,
downloadingId,
downloadLabels,
}: LightboxProps) {
const touchStart = useRef<{ x: number; y: number } | null>(null);
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const item = items[index];
const hasMultiple = items.length > 1;
const downloading = !!downloadingId && item?.id === downloadingId;
const prev = useCallback(() => {
onNavigate(index > 0 ? index - 1 : items.length - 1);
@@ -76,33 +53,19 @@ export default function Lightbox({
};
}, [onClose, prev, next]);
// Keep the active thumbnail centred in the filmstrip as you navigate.
useEffect(() => {
activeThumbRef.current?.scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'nearest',
});
}, [index]);
if (!item) return null;
return (
<div
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
onClick={onClose}
onTouchStart={(e) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}}
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
onTouchEnd={(e) => {
if (touchStart.current === null) return;
const dx = e.changedTouches[0].clientX - touchStart.current.x;
const dy = e.changedTouches[0].clientY - touchStart.current.y;
touchStart.current = null;
// Only treat as a swipe when it's clearly horizontal.
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
if (dx > 0) prev();
else next();
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (dx > 60) prev();
if (dx < -60) next();
setTouchStartX(null);
}}
>
<button
@@ -117,122 +80,58 @@ export default function Lightbox({
className="absolute top-4 left-4 z-10 flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
{onDownload ? (
<button
type="button"
onClick={() => onDownload(item)}
disabled={downloading}
aria-busy={downloading}
aria-label={
downloading
? downloadLabels?.downloading || 'Downloading…'
: downloadLabels?.download || 'Download'
}
className={`text-white hover:text-gray-300 flex items-center ${
downloading ? 'cursor-wait' : ''
}`}
>
{downloading ? (
<Spinner className="w-6 h-6" />
) : (
<ArrowDownTrayIcon className="w-7 h-7" />
)}
</button>
) : (
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
)}
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
{renderActions?.(item, index)}
</div>
{/* Main image area */}
<div className="relative flex-1 min-h-0 flex items-center justify-center">
{hasMultiple && (
<>
<button
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{items.length > 1 && (
<>
<button
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
{/* Nothing is layered over the image (the navigation buttons sit at
the edges), and neither -webkit-touch-callout nor -webkit-user-
select is suppressed here, so iOS long-press "Save to Photos"
still works as a backup to the save sheet. Don't add select-none. */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-full object-contain [-webkit-touch-callout:default]"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
</div>
{/* Counter + thumbnail filmstrip you can skip through. */}
<div
className="shrink-0 pb-3 pt-2"
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
<div className="text-center text-white/70 text-sm mb-2">
{index + 1} / {items.length}
</div>
{hasMultiple && (
<div className="flex gap-2 overflow-x-auto px-4 pb-1 justify-start sm:justify-center [scrollbar-width:thin]">
{items.map((it, i) => (
<button
key={it.id}
ref={i === index ? activeThumbRef : null}
onClick={() => onNavigate(i)}
aria-label={`Photo ${i + 1}`}
aria-current={i === index}
className={`relative shrink-0 overflow-hidden rounded transition ${
i === index
? 'ring-2 ring-white opacity-100'
: 'opacity-50 hover:opacity-90'
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={it.thumbUrl || it.previewUrl}
alt=""
className="h-14 w-14 sm:h-16 sm:w-16 object-cover"
draggable={false}
/>
</button>
))}
</div>
)}
draggable={false}
/>
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
{index + 1} / {items.length}
</div>
{/* Preload neighbours so prev/next feels instant. */}
<div className="hidden" aria-hidden>
{hasMultiple && (
{items.length > 1 && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
-190
View File
@@ -1,190 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { authApi } from '@/lib/api';
import { XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
interface LoginModalProps {
open: boolean;
onClose: () => void;
/** Called after a successful password login (Google logins reload the page). */
onSuccess?: () => void;
/** Optional context line shown under the title (e.g. why login is needed). */
message?: string;
}
/**
* Reusable login pop-up: same capabilities as /login (password,
* magic link, Google) but inline, so the visitor stays on the page
* used by gated photo galleries.
*/
export default function LoginModal({ open, onClose, onSuccess, message }: LoginModalProps) {
const { t, locale } = useLanguage();
const es = locale === 'es';
const { login } = useAuth();
const pathname = usePathname();
const searchParams = useSearchParams();
const [mode, setMode] = useState<'password' | 'magic-link'>('password');
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Google logins hard-reload; send them back to exactly where they are
// (including a ?token= share link).
const query = searchParams.toString();
const currentUrl = query ? `${pathname}?${query}` : pathname;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
toast.success(es ? '¡Bienvenido!' : 'Welcome back!');
onClose();
onSuccess?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t('auth.errors.invalidCredentials'));
} finally {
setLoading(false);
}
};
const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error(es ? 'Ingresa tu email' : 'Please enter your email');
return;
}
setLoading(true);
try {
await authApi.requestMagicLink(email);
setMagicLinkSent(true);
toast.success(es ? 'Revisa tu correo para el enlace de acceso' : 'Check your email for the login link');
} catch (error) {
toast.error(error instanceof Error ? error.message : es ? 'Error' : 'Failed');
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<Card className="w-full max-w-md p-6 md:p-8" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between mb-1">
<h2 className="text-xl font-bold text-primary-dark">{t('auth.login.title')}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1 -m-1" aria-label="Close">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
{message && <p className="text-sm text-gray-600 mb-4">{message}</p>}
<div className="mt-4">
<GoogleSignInButton redirectTo={currentUrl} />
</div>
<div className="flex items-center gap-3 my-5">
<div className="flex-1 h-px bg-secondary-light-gray" />
<span className="text-xs text-gray-500">{es ? 'o' : 'or'}</span>
<div className="flex-1 h-px bg-secondary-light-gray" />
</div>
{mode === 'password' ? (
<form onSubmit={handlePasswordLogin} className="space-y-4">
<Input
id="login-modal-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="login-modal-password"
label={t('auth.login.password')}
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{t('auth.login.submit')}
</Button>
</form>
) : magicLinkSent ? (
<p className="text-sm text-gray-600 text-center py-4">
{es
? 'Te enviamos un enlace de acceso. Abre tu correo y vuelve a esta página.'
: 'We sent you a login link. Open your email and come back to this page.'}
</p>
) : (
<form onSubmit={handleMagicLink} className="space-y-4">
<Input
id="login-modal-magic-email"
label={t('auth.login.email')}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button type="submit" className="w-full" isLoading={loading}>
{es ? 'Enviarme un enlace de acceso' : 'Email me a login link'}
</Button>
</form>
)}
<div className="mt-4 flex items-center justify-between text-sm">
<button
type="button"
onClick={() => {
setMode(mode === 'password' ? 'magic-link' : 'password');
setMagicLinkSent(false);
}}
className="text-secondary-blue hover:underline"
>
{mode === 'password'
? es
? 'Entrar con enlace por correo'
: 'Log in with an email link'
: es
? 'Entrar con contraseña'
: 'Log in with a password'}
</button>
<Link href="/register" className="text-secondary-blue hover:underline" onClick={onClose}>
{es ? 'Crear cuenta' : 'Create account'}
</Link>
</div>
</Card>
</div>
);
}
@@ -1,119 +0,0 @@
'use client';
import { useEffect } from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
/**
* Page buttons to show: first, last, the current page and its neighbours, with
* ellipses standing in for the gaps once there are more than 7 pages.
*/
export function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: (number | '...')[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) pages.push('...');
pages.push(totalPages);
return pages;
}
/**
* Slices a list for client-side pagination and keeps the page in range when the
* list shrinks underneath it (filter change, deletion, refresh).
*/
export function usePaginatedList<T>(items: T[], page: number, pageSize: number, setPage: (page: number) => void) {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages, setPage]);
const safePage = Math.min(page, totalPages);
return items.slice((safePage - 1) * pageSize, safePage * pageSize);
}
interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
/** Unique per page — the per-page <select> needs its own id for the label. */
id: string;
className?: string;
}
export default function Pagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
id,
className,
}: PaginationProps) {
if (total === 0) return null;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className={clsx('mt-4 flex flex-col sm:flex-row items-center justify-between gap-3', className)}>
<div className="flex items-center gap-2 text-sm text-gray-600">
<label htmlFor={`${id}-page-size`} className="whitespace-nowrap">Per page</label>
<select
id={`${id}-page-size`}
value={pageSize}
onChange={(e) => { onPageSizeChange(Number(e.target.value)); onPageChange(1); }}
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="text-xs text-gray-500 whitespace-nowrap">
{(page - 1) * pageSize + 1}&ndash;{Math.min(page * pageSize, total)} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{getPageNumbers(page, totalPages).map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">&hellip;</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={clsx(
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
p === page
? 'bg-primary-yellow text-primary-dark font-semibold'
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
);
}
@@ -1,46 +0,0 @@
// Layout shell shared by the public gallery page (GalleryClient) and its
// loading placeholder (GallerySkeleton). Column count, gaps, radii and
// container padding are defined once here, so the skeleton's geometry can
// never drift from the grid it stands in for.
export function GalleryHeroFrame({
children,
backdrop,
}: {
children: React.ReactNode;
/** Cover image + scrim, absolutely positioned behind the text. */
backdrop?: React.ReactNode;
}) {
return (
<div className="relative bg-brand-navy overflow-hidden">
{backdrop}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">{children}</div>
</div>
);
}
export function GalleryContainer({ children }: { children: React.ReactNode }) {
return <div className="container-page px-2 sm:px-4 py-4 md:py-8">{children}</div>;
}
export function MasonryGrid({ children }: { children: React.ReactNode }) {
return (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
{children}
</div>
);
}
// Per-tile geometry. Deliberately carries no background so callers can pick
// one (photo tiles: bg-gray-100, skeleton tiles: the skeleton surface) without
// two `bg-*` utilities fighting over CSS order.
export const masonryTileClass = 'mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl';
/**
* Reserves the tile's aspect ratio up front so the image can load into a box
* that is already the right size late arrivals never reflow the columns.
* Returns undefined when the photo has no stored dimensions.
*/
export function aspectStyle(width?: number, height?: number): React.CSSProperties | undefined {
return width && height ? { aspectRatio: `${width} / ${height}` } : undefined;
}
@@ -1,59 +0,0 @@
import clsx from 'clsx';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
import { GalleryContainer, GalleryHeroFrame, MasonryGrid, masonryTileClass } from './GalleryLayout';
// Loading placeholder for the public gallery page. It renders through the same
// hero frame, container and masonry grid as the real page (see GalleryLayout),
// so replacing it with photos changes only the pixels inside the tiles.
// Portrait/landscape/square mix standing in for a real event set. Fixed order
// on purpose — a random shuffle would differ between the server and client
// render and blow up hydration.
const FALLBACK_RATIOS = [3 / 4, 4 / 3, 1, 2 / 3, 3 / 2, 4 / 5, 1, 3 / 4, 16 / 9, 4 / 5, 3 / 4, 4 / 3];
interface GallerySkeletonProps {
/** Number of tiles to draw; ignored when `ratios` is given. */
count?: number;
/**
* Real width/height ratios when the caller already knows them (e.g. a
* cached gallery payload). Produces a grid with exactly the right geometry
* instead of the guessed mix above.
*/
ratios?: number[];
}
export default function GallerySkeleton({ count = 12, ratios }: GallerySkeletonProps) {
const tiles =
ratios && ratios.length > 0
? ratios
: Array.from({ length: count }, (_, i) => FALLBACK_RATIOS[i % FALLBACK_RATIOS.length]);
return (
<SkeletonGroup>
<GalleryHeroFrame>
{/* Matches the h1 (text-3xl / md:text-5xl) and the pill row below it. */}
<Skeleton tone="on-dark" className="h-9 md:h-12 w-2/3 max-w-md" />
<div className="mt-4 flex flex-wrap items-center gap-2">
<Skeleton tone="on-dark" className="h-7 w-28 rounded-full" />
<Skeleton tone="on-dark" className="h-7 w-56 max-w-[60%] rounded-full" />
</div>
</GalleryHeroFrame>
<GalleryContainer>
<MasonryGrid>
{tiles.map((ratio, i) => (
<div
key={i}
aria-hidden="true"
className={clsx(
masonryTileClass,
'bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none'
)}
style={{ aspectRatio: `${ratio}` }}
/>
))}
</MasonryGrid>
</GalleryContainer>
</SkeletonGroup>
);
}
@@ -1,102 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import type { Photo } from '@/lib/api';
import Spinner from '@/components/ui/Spinner';
import { aspectStyle, masonryTileClass } from './GalleryLayout';
interface PhotoTileProps {
photo: Photo;
/** Above-the-fold tiles load eagerly, the rest lazily. */
eager?: boolean;
onOpen: () => void;
onDownload: () => void;
downloading: boolean;
labels: { download: string; downloading: string };
}
export default function PhotoTile({
photo,
eager,
onOpen,
onDownload,
downloading,
labels,
}: PhotoTileProps) {
const imgRef = useRef<HTMLImageElement>(null);
// 'initial' is what the server renders: fully visible, so a public gallery
// still paints its photos if hydration is slow or JS never arrives. The
// fade only takes over once we're mounted and know the image is still in
// flight — a cached image reports `complete` here and skips it entirely
// (its onLoad already fired before React attached the handler).
const [state, setState] = useState<'initial' | 'pending' | 'loaded'>('initial');
const pending = state === 'pending';
useEffect(() => {
setState(imgRef.current?.complete ? 'loaded' : 'pending');
}, []);
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen();
}
}}
className={clsx(
masonryTileClass,
'group relative bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow'
)}
/* Sized from the stored dimensions before the bytes arrive, so a slow
image never pushes its column around. */
style={aspectStyle(photo.width, photo.height)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={photo.urls.thumb}
alt=""
loading={eager ? 'eager' : 'lazy'}
onLoad={() => setState('loaded')}
onError={() => setState('loaded')}
className={clsx(
'w-full h-auto transition-[opacity,transform] duration-300 motion-reduce:transition-none group-hover:scale-[1.03]',
pending ? 'opacity-0' : 'opacity-100'
)}
/>
{pending && (
<span
aria-hidden="true"
className="absolute inset-0 bg-secondary-light-gray/70 animate-pulse motion-reduce:animate-none"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
disabled={downloading}
aria-busy={downloading}
aria-label={downloading ? labels.downloading : labels.download}
className={clsx(
'absolute bottom-2 right-2 flex p-2.5 md:p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
// Touch devices have no hover to reveal it, and this is where the
// button matters most — it is the entry point to the save sheet.
downloading
? 'opacity-100 cursor-wait hover:bg-black/50'
: 'opacity-100 md:opacity-0 md:group-hover:opacity-100'
)}
>
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-5 h-5 md:w-4 md:h-4" />}
</button>
</div>
);
}
@@ -1,370 +0,0 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowDownTrayIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import Spinner from '@/components/ui/Spinner';
// The mobile save flow. On a phone `<a download>` is a dead end for someone
// who wants the photo in their camera roll: iOS is WebKit everywhere, so it
// files the download under Files > Downloads and nothing else, and Android
// drops it in Downloads where Google Photos may or may not pick it up. The
// only route into the device gallery on either platform is the Web Share
// API, which hands the file to the native share sheet ("Save Image" on iOS,
// "Photos" on Android).
//
// So on mobile the download button opens this sheet instead of downloading,
// and the default row shares the 2048px preview — a file a phone screen
// cannot tell from the original, at a fraction of the bytes. The original
// stays one tap away for anyone who actually wants it.
export interface SavePhoto {
id: string;
/** Preview download URL the same URL the lightbox renders, so this is
* usually answered from the HTTP cache rather than the network. */
previewUrl: string;
originalUrl: string;
/** Byte sizes from the gallery response, used to label the rows. */
previewSize?: number;
originalSize?: number;
}
type RowKey = 'preview' | 'original';
type RowStatus = 'idle' | 'loading' | 'ready' | 'error';
interface RowState {
status: RowStatus;
/** 0..1, only tracked for the original's determinate progress bar. */
progress: number;
}
const IDLE: RowState = { status: 'idle', progress: 0 };
/**
* True on finger-first devices, i.e. where a plain download lands in
* Downloads instead of the photo library and this sheet is worth showing.
* Feature detection only user agent strings lie, and iPad has been
* claiming to be a Mac for years.
*
* Deliberately NOT gated on `navigator.share`. Web Share only exists in a
* secure context, so requiring it here made the sheet disappear entirely on
* any plain-HTTP origin (a phone hitting the dev server over the LAN sees
* `navigator.share === undefined`) and on Firefox for Android, which has no
* file sharing. Both of those should still get the sheet their rows just
* fall back to a download. Whether a given row shares or downloads is
* decided per file, at tap time, by canShareFile() below.
*/
export function useMobileSave(): boolean {
const [mobile, setMobile] = useState(false);
useEffect(() => {
// Comma = OR. `hover: none` catches touch devices that report a fine
// pointer because a stylus is paired.
const mq = window.matchMedia('(pointer: coarse), (hover: none)');
const sync = () => setMobile(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
return mobile;
}
function canShareFile(file: File): boolean {
return typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
}
/** Reads the server's Content-Disposition name (spanglish-<event>-<n>.jpg). */
function filenameFrom(header: string | null, fallback: string): string {
const match = header?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
return match ? decodeURIComponent(match[1]) : fallback;
}
function formatBytes(bytes: number | undefined, locale: string): string | null {
if (!bytes || bytes <= 0) return null;
const mb = bytes / 1_000_000;
if (mb < 1) {
return `${Math.round(bytes / 1000).toLocaleString(locale)} kB`;
}
return `${mb.toLocaleString(locale, { maximumFractionDigits: 1 })} MB`;
}
/**
* Fetches a URL into a File. onProgress is only wired up when the caller
* wants a determinate bar reading the body in chunks costs an extra copy,
* which is not worth it for the preview that is normally already cached.
*/
async function fetchAsFile(
url: string,
signal: AbortSignal,
onProgress?: (fraction: number) => void
): Promise<File> {
// No `cache` option: the default is what lets the lightbox's already
// rendered preview be reused instead of refetched.
const res = await fetch(url, { credentials: 'same-origin', signal });
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const type = res.headers.get('Content-Type') || 'image/jpeg';
const name = filenameFrom(res.headers.get('Content-Disposition'), 'spanglish-photo.jpg');
const total = Number(res.headers.get('Content-Length') || 0);
let blob: Blob;
if (onProgress && total > 0 && res.body) {
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
onProgress(Math.min(1, received / total));
}
blob = new Blob(chunks as BlobPart[], { type });
} else {
blob = await res.blob();
}
return new File([blob], name, { type: blob.type || type });
}
/** Fallback for browsers without file sharing: a plain download. */
function saveViaAnchor(file: File) {
const objectUrl = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = objectUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
interface SaveSheetProps {
/** The photo to save; null keeps the sheet closed. */
photo: SavePhoto | null;
onClose: () => void;
}
export default function SaveSheet({ photo, onClose }: SaveSheetProps) {
if (!photo) return null;
// Keyed by photo so a sheet opened for a different photo starts clean
// instead of inheriting the previous one's fetched file.
return <SaveSheetPanel key={photo.id} photo={photo} onClose={onClose} />;
}
function SaveSheetPanel({ photo, onClose }: { photo: SavePhoto; onClose: () => void }) {
const { t, locale } = useLanguage();
const [rows, setRows] = useState<Record<RowKey, RowState>>({
preview: IDLE,
original: IDLE,
});
// Resolved files live in a ref as well as state: the tap handler has to
// read them *synchronously*. iOS rejects navigator.share() with
// NotAllowedError if anything is awaited between the tap and the call,
// so there is no chance to read them out of a promise first.
const files = useRef<Partial<Record<RowKey, File>>>({});
const aborter = useRef<AbortController>(new AbortController());
const closed = useRef(false);
const setRow = useCallback((key: RowKey, patch: Partial<RowState>) => {
if (closed.current) return;
setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
}, []);
const prepare = useCallback(
async (key: RowKey): Promise<File | null> => {
setRow(key, { status: 'loading', progress: 0 });
try {
const file = await fetchAsFile(
key === 'preview' ? photo.previewUrl : photo.originalUrl,
aborter.current.signal,
// Only the original gets a determinate bar; the preview is
// normally cached and resolves before a spinner would even paint.
key === 'original' ? (progress) => setRow(key, { progress }) : undefined
);
files.current[key] = file;
setRow(key, { status: 'ready', progress: 1 });
return file;
} catch (err) {
// Aborting is how the sheet closes and how the user dismisses the
// native share sheet — neither is an error worth reporting.
if ((err as Error)?.name === 'AbortError') return null;
setRow(key, { status: 'error', progress: 0 });
return null;
}
},
[photo.previewUrl, photo.originalUrl, setRow]
);
// Nothing is fetched until the sheet is open — not on lightbox open, not
// on swipe, not on scroll. Browsing the whole gallery without tapping
// save costs exactly the thumbs and previews already on screen.
useEffect(() => {
// The controller is created here, not at render: closing the sheet
// aborts it, so a re-run of this effect (React StrictMode double-invokes
// it in development) has to start from a fresh, unaborted one.
closed.current = false;
const controller = new AbortController();
aborter.current = controller;
void prepare('preview');
return () => {
closed.current = true;
controller.abort();
};
}, [prepare]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const deliver = (file: File) => {
if (canShareFile(file)) {
// Called with nothing awaited in front of it — see the ref above.
navigator
.share({ files: [file] })
.then(() => onClose())
.catch((err: Error) => {
// AbortError just means the share sheet was dismissed.
if (err?.name !== 'AbortError') saveViaAnchor(file);
});
return;
}
saveViaAnchor(file);
onClose();
};
const onRowTap = (key: RowKey) => {
const ready = files.current[key];
if (ready) {
deliver(ready);
return;
}
if (rows[key].status === 'loading') return;
// Not fetched yet (or it failed): fetch now and stop. The row goes back
// to tappable when it resolves, so the share happens on a fresh
// gesture rather than a stale one iOS would refuse.
void prepare(key);
};
const sizes = {
preview: formatBytes(photo.previewSize, locale),
original: formatBytes(photo.originalSize, locale),
};
return (
<div
className="fixed inset-0 z-[60] flex flex-col justify-end"
role="dialog"
aria-modal="true"
aria-label={t('gallery.save.sheetTitle')}
>
<button
type="button"
aria-label={t('gallery.save.cancel')}
className="absolute inset-0 bg-black/60 motion-safe:animate-sheet-fade"
onClick={onClose}
/>
<div className="relative w-full sm:max-w-md sm:mx-auto bg-white rounded-t-2xl sm:rounded-2xl sm:mb-4 shadow-xl p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] motion-safe:animate-sheet-rise">
<div className="mx-auto mb-2 mt-1 h-1 w-10 rounded-full bg-gray-300" aria-hidden />
<SaveRow
icon={<PhotoIcon className="w-6 h-6 text-primary-dark" />}
label={t('gallery.save.photo')}
hint={t('gallery.save.photoHint')}
size={sizes.preview}
state={rows.preview}
statusLabels={t}
onTap={() => onRowTap('preview')}
/>
<SaveRow
icon={<ArrowDownTrayIcon className="w-6 h-6 text-primary-dark" />}
label={t('gallery.save.original')}
hint={t('gallery.save.originalHint')}
size={sizes.original}
state={rows.original}
statusLabels={t}
showProgress
onTap={() => onRowTap('original')}
/>
<button
type="button"
onClick={onClose}
className="mt-1 w-full rounded-xl py-3 text-center font-medium text-gray-600 hover:bg-gray-50 active:bg-gray-100"
>
{t('gallery.save.cancel')}
</button>
</div>
</div>
);
}
function SaveRow({
icon,
label,
hint,
size,
state,
statusLabels,
showProgress,
onTap,
}: {
icon: React.ReactNode;
label: string;
hint: string;
size: string | null;
state: RowState;
statusLabels: (key: string) => string;
showProgress?: boolean;
onTap: () => void;
}) {
const loading = state.status === 'loading';
// The subtitle carries the row's state, so a slow fetch explains itself
// without the row ever becoming untappable for good.
const subtitle =
state.status === 'loading'
? statusLabels('gallery.save.preparing')
: state.status === 'error'
? statusLabels('gallery.save.failed')
: [hint, size].filter(Boolean).join(' · ');
return (
<button
type="button"
onClick={onTap}
aria-busy={loading}
className="relative w-full overflow-hidden rounded-xl px-4 py-3 text-left flex items-center gap-3 hover:bg-gray-50 active:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow"
>
<span className="shrink-0">
{loading ? (
// Spinner defaults to the white ring used over photos; this one
// sits on the sheet's white surface.
<Spinner className="w-6 h-6 border-gray-300 border-t-primary-dark" />
) : (
icon
)}
</span>
<span className="min-w-0">
<span className="block font-medium text-primary-dark">{label}</span>
<span
className={`block text-sm ${state.status === 'error' ? 'text-red-600' : 'text-gray-500'}`}
>
{subtitle}
</span>
</span>
{showProgress && loading && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-gray-200" aria-hidden>
<span
className="block h-full bg-primary-yellow transition-[width] duration-150"
style={{ width: `${Math.round(state.progress * 100)}%` }}
/>
</span>
)}
</button>
);
}
@@ -1,148 +0,0 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
// Per-photo download state for the public gallery. The photo-api serves full
// quality originals, which takes a few seconds, so downloads run through
// fetch() instead of a bare <a download> — that gives us a spinner, a real
// success/failure signal, a timeout and abort-on-unmount.
// Originals run to ~20 MB, so a single flat timeout would either be too short
// for a phone on mobile data or useless as a stall detector. Time out on the
// response headers instead, then give the body a generous ceiling.
const HEADERS_TIMEOUT_MS = 30_000;
const BODY_TIMEOUT_MS = 10 * 60_000;
export interface DownloadRequest {
/** Photo id; keys the in-flight state so tiles stay independent. */
id: string;
url: string;
filename?: string;
}
/** Saves fetched bytes without navigating away from the gallery. */
function saveBlob(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Give the browser time to start the save before the URL is invalidated.
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
/**
* Plain-anchor download, i.e. what the page did before this hook existed.
* Used as a fallback when fetch() itself fails: with S3 storage the file
* endpoint 302s to a presigned URL on another origin, which a cross-origin
* fetch cannot read but a navigation downloads fine (the presigned URL
* carries its own Content-Disposition).
*/
function navigateToDownload({ url, filename }: DownloadRequest) {
const a = document.createElement('a');
a.href = url;
if (filename) a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
export function useDownloads(es: boolean) {
const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
const controllers = useRef(new Map<string, AbortController>());
const unmounted = useRef(false);
// Lets the retry button in the error toast call the latest `start`.
const startRef = useRef<(req: DownloadRequest) => void>(() => {});
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
controllers.current.forEach((c) => c.abort());
controllers.current.clear();
};
}, []);
const start = useCallback(
async (req: DownloadRequest) => {
// Repeat clicks while the same photo is in flight are no-ops; other
// photos are unaffected because state is keyed by id.
if (controllers.current.has(req.id)) return;
const controller = new AbortController();
controllers.current.set(req.id, controller);
setPending((prev) => new Set(prev).add(req.id));
let timer = setTimeout(() => controller.abort(), HEADERS_TIMEOUT_MS);
const timedOut = () => controller.signal.aborted && !unmounted.current;
try {
const res = await fetch(req.url, {
credentials: 'same-origin',
signal: controller.signal,
});
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), BODY_TIMEOUT_MS);
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const blob = await res.blob();
saveBlob(blob, req.filename || `${req.id}.jpg`);
} catch (err) {
if (unmounted.current) return; // page gone, nothing to report
if (err instanceof TypeError) {
// Network-level failure — most likely a cross-origin presigned
// redirect. Hand it to the browser, which can follow it.
navigateToDownload(req);
} else {
const message = timedOut()
? es
? 'La descarga tardó demasiado.'
: 'The download timed out.'
: es
? 'No se pudo descargar la foto.'
: 'Could not download the photo.';
toast.error(
(t) => (
<span className="flex items-center gap-3">
{message}
<button
type="button"
onClick={() => {
toast.dismiss(t.id);
startRef.current(req);
}}
className="font-medium underline underline-offset-2 whitespace-nowrap"
>
{es ? 'Reintentar' : 'Retry'}
</button>
</span>
),
{ duration: 6000 }
);
}
} finally {
clearTimeout(timer);
controllers.current.delete(req.id);
if (!unmounted.current) {
setPending((prev) => {
const next = new Set(prev);
next.delete(req.id);
return next;
});
}
}
},
[es]
);
useEffect(() => {
startRef.current = start;
}, [start]);
const isPending = useCallback((id: string) => pending.has(id), [pending]);
return { start, isPending };
}
+2 -16
View File
@@ -7,24 +7,10 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
/** Label shown in place of the children while loading. */
loadingText?: string;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading,
loadingText = 'Loading...',
children,
disabled,
...props
},
ref
) => {
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
@@ -76,7 +62,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{loadingText}
Loading...
</>
) : (
children
+2 -12
View File
@@ -9,21 +9,11 @@ interface SkeletonProps {
className?: string;
}
export function Skeleton({
className,
tone = 'default',
}: SkeletonProps & {
/** `on-dark` lightens the surface for placeholders over a dark hero. */
tone?: 'default' | 'on-dark';
}) {
export function Skeleton({ className }: SkeletonProps) {
return (
<div
aria-hidden="true"
className={clsx(
'animate-pulse motion-reduce:animate-none rounded-lg',
tone === 'on-dark' ? 'bg-white/20' : 'bg-secondary-light-gray/70',
className
)}
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)}
/>
);
}
-18
View File
@@ -1,18 +0,0 @@
import clsx from 'clsx';
// Inline busy indicator in the same style as the full-page loaders
// (animate-spin ring with one contrasting edge). Purely decorative: callers
// own the accessible state via aria-busy / aria-label. Defaults to the
// white-on-dark ring used by the photo overlay controls; pass `border-*`
// classes to restyle it elsewhere.
export default function Spinner({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={clsx(
'inline-block rounded-full border-2 animate-spin border-white/40 border-t-white',
className || 'w-4 h-4'
)}
/>
);
}
+136 -103
View File
@@ -1,8 +1,8 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { authClient } from '@/lib/auth-client';
import { fetchApi } from '@/lib/api/client';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
interface User {
id: string;
@@ -18,19 +18,18 @@ interface User {
interface AuthContextType {
user: User | null;
/** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */
token: string | null;
isLoading: boolean;
isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<User | null>;
loginWithMagicLink: (token: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
updateUser: (user: User) => void;
setAuthData: (data: { user: User; token?: string }) => void;
refreshUser: () => Promise<User | null>;
setAuthData: (data: { user: User; token: string }) => void;
refreshUser: () => Promise<void>;
}
interface RegisterData {
@@ -43,144 +42,178 @@ interface RegisterData {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Legacy storage from the pre-Better-Auth JWT era; cleared once on mount.
const LEGACY_TOKEN_KEY = 'spanglish-token';
const LEGACY_USER_KEY = 'spanglish-user';
const LEGACY_AUTH_COOKIE = 'spanglish-auth';
const TOKEN_KEY = 'spanglish-token';
const USER_KEY = 'spanglish-user';
const AUTH_COOKIE = 'spanglish-auth';
function mapSessionUser(sessionUser: any): User {
return {
id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
role: sessionUser.role ?? 'user',
phone: sessionUser.phone ?? undefined,
languagePreference: sessionUser.languagePreference ?? undefined,
isClaimed: Boolean(sessionUser.isClaimed ?? true),
rucNumber: sessionUser.rucNumber ?? undefined,
accountStatus: sessionUser.accountStatus ?? 'active',
};
function setAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
}
function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string {
return error?.message || fallback;
function clearAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async (): Promise<User | null> => {
const refreshUser = useCallback(async () => {
const currentToken = localStorage.getItem(TOKEN_KEY);
if (!currentToken) return;
try {
const { data } = await authClient.getSession();
if (data?.user) {
const mapped = mapSessionUser(data.user);
setUser(mapped);
return mapped;
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${currentToken}`,
'Content-Type': 'application/json',
},
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
} else if (res.status === 401) {
// Token is invalid, clear auth state
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}
setUser(null);
return null;
} catch (error) {
// Network error: keep current state
// Network error, keep using cached data
console.error('Failed to refresh user data:', error);
return null;
}
}, []);
useEffect(() => {
// One-time cleanup of legacy JWT-era storage
try {
localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
} catch {
/* SSR / storage unavailable */
}
// Load auth state from localStorage
const savedToken = localStorage.getItem(TOKEN_KEY);
const savedUser = localStorage.getItem(USER_KEY);
refreshUser().finally(() => setIsLoading(false));
if (savedToken && savedUser) {
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
let parsedUser: User | null = null;
try {
parsedUser = JSON.parse(savedUser);
} catch {
parsedUser = null;
}
if (parsedUser) {
setToken(savedToken);
setUser(parsedUser);
// Refresh user data from server to get latest role/permissions (source of truth)
refreshUser().finally(() => setIsLoading(false));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setIsLoading(false);
}
} else {
setIsLoading(false);
}
}, [refreshUser]);
const setAuthData = useCallback((data: { user: User; token: string }) => {
setToken(data.token);
setUser(data.user);
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
}, []);
const login = async (email: string, password: string) => {
const { error } = await authClient.signIn.email({ email, password });
if (error) {
throw new Error(messageFrom(error, 'Login failed'));
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Login failed');
}
await refreshUser();
const data = await res.json();
setAuthData(data);
};
const loginWithGoogle = async (credential: string) => {
const { error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
const res = await fetch(`${API_BASE}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
});
if (error) {
// Carry the code through: GoogleSignInButton turns OAUTH_LINK_ERROR into
// something actionable instead of showing better-auth's bare
// "account not linked".
const err = new Error(messageFrom(error, 'Google login failed'));
(err as Error & { code?: string }).code = error.code;
throw err;
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Google login failed');
}
await refreshUser();
const data = await res.json();
setAuthData(data);
};
const loginWithMagicLink = async (magicToken: string): Promise<User | null> => {
const { error } = await authClient.magicLink.verify({ query: { token: magicToken } });
if (error) {
throw new Error(messageFrom(error, 'Invalid or expired link'));
const loginWithMagicLink = async (magicToken: string) => {
const res = await fetch(`${API_BASE}/api/auth/magic-link/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: magicToken }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Magic link login failed');
}
return refreshUser();
const data = await res.json();
setAuthData(data);
};
const register = async (registerData: RegisterData) => {
const { error } = await authClient.signUp.email({
email: registerData.email,
password: registerData.password,
name: registerData.name,
phone: registerData.phone || undefined,
languagePreference: registerData.languagePreference || undefined,
} as any);
if (error) {
// Existing-but-unclaimed accounts (created during guest booking) should
// point the user to the claim flow instead of a bare "already exists".
if (error.code === 'USER_ALREADY_EXISTS') {
try {
const { canClaim } = await fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}`
);
if (canClaim) {
const err = new Error(
'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.'
);
(err as any).canClaim = true;
throw err;
}
} catch (e: any) {
if (e?.canClaim) throw e;
/* eligibility check failed: fall through to generic message */
}
}
throw new Error(messageFrom(error, 'Registration failed'));
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registerData),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Registration failed');
}
await refreshUser();
const data = await res.json();
setAuthData(data);
};
const logout = useCallback(() => {
// Best-effort server-side revocation; local state clears regardless.
authClient.signOut().catch(() => {
/* ignore network errors */
});
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
const currentToken = localStorage.getItem(TOKEN_KEY);
if (currentToken) {
fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` },
}).catch(() => {
// Ignore network errors; local state is cleared regardless.
});
}
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}, []);
const updateUser = useCallback((updatedUser: User) => {
setUser(updatedUser);
}, []);
// Compat shim for callers that used to push {user, token} after custom auth
// flows; the session cookie is already set by then, so only state updates.
const setAuthData = useCallback((data: { user: User; token?: string }) => {
setUser(data.user);
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
}, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
@@ -190,7 +223,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
<AuthContext.Provider
value={{
user,
token: null,
token,
isLoading,
isAdmin,
hasAdminAccess,
-18
View File
@@ -235,9 +235,6 @@
"email": "Email",
"password": "Password",
"submit": "Sign In",
"redirecting": "Redirecting...",
"redirectBlocked": "You're signed in, but we couldn't open that page.",
"redirectRetry": "Try again",
"noAccount": "Don't have an account?",
"register": "Sign Up"
},
@@ -367,20 +364,5 @@
"title": "TikTok",
"subtitle": "Videos & fun content"
}
},
"gallery": {
"save": {
"sheetTitle": "Save photo",
"photo": "Save photo",
"photoHint": "Best for phone",
"original": "Download original",
"originalHint": "Full quality",
"cancel": "Cancel",
"preparing": "Preparing…",
"ready": "Ready — tap to save",
"failed": "Could not prepare it. Tap to retry.",
"download": "Download",
"downloading": "Downloading…"
}
}
}
-18
View File
@@ -235,9 +235,6 @@
"email": "Email",
"password": "Contraseña",
"submit": "Iniciar Sesión",
"redirecting": "Redirigiendo...",
"redirectBlocked": "Has iniciado sesión, pero no pudimos abrir esa página.",
"redirectRetry": "Intentar de nuevo",
"noAccount": "¿No tienes cuenta?",
"register": "Registrarse"
},
@@ -367,20 +364,5 @@
"title": "TikTok",
"subtitle": "Videos y contenido divertido"
}
},
"gallery": {
"save": {
"sheetTitle": "Guardar foto",
"photo": "Guardar foto",
"photoHint": "Ideal para el teléfono",
"original": "Descargar original",
"originalHint": "Calidad completa",
"cancel": "Cancelar",
"preparing": "Preparando…",
"ready": "Lista: toca para guardar",
"failed": "No se pudo preparar. Toca para reintentar.",
"download": "Descargar",
"downloading": "Descargando…"
}
}
}
+50 -84
View File
@@ -1,97 +1,63 @@
import { authClient } from '../auth-client';
import { fetchApi } from './client';
import type { User } from './types';
// Thin wrappers over the Better Auth client, preserving the legacy authApi
// call surface used by the auth pages.
type ClientError = { message?: string; code?: string; status: number } | null;
function throwIfError(error: ClientError, fallback: string): void {
if (error) {
const err = new Error(error.message || fallback);
(err as any).code = error.code;
(err as any).status = error.status;
throw err;
}
}
export const authApi = {
// Magic link login. Enumeration-safe UX parity: unknown emails resolve with
// the same generic message (magic links never create accounts server-side);
// only rate limiting surfaces as an error.
requestMagicLink: async (email: string, callbackURL: string = '/dashboard') => {
const { error } = await authClient.signIn.magicLink({ email, callbackURL });
if (error && error.status === 429) {
throwIfError(error, 'Too many requests. Please try again later.');
}
return { message: 'If an account exists with this email, a login link has been sent.' };
},
verifyMagicLink: async (token: string) => {
const { data, error } = await authClient.magicLink.verify({ query: { token } });
throwIfError(error, 'Invalid or expired token');
return data;
},
// Password reset (Better Auth is enumeration-safe here by default)
requestPasswordReset: async (email: string) => {
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: '/auth/reset-password',
});
throwIfError(error, 'Failed to request password reset');
return { message: 'If an account exists with this email, a password reset link has been sent.' };
},
confirmPasswordReset: async (token: string, password: string) => {
const { error } = await authClient.resetPassword({ newPassword: password, token });
throwIfError(error, 'Invalid or expired token');
return { message: 'Password reset successfully. Please log in with your new password.' };
},
// Account claiming: a magic link that lands on the claim page, where the
// session-holding user sets a password via /api/auth-ext/claim-account.
requestClaimAccount: (email: string) =>
authApi.requestMagicLink(email, '/auth/claim-account'),
confirmClaimAccount: (password: string) =>
fetchApi<{ user: User; message: string }>('/api/auth-ext/claim-account', {
// Magic link
requestMagicLink: (email: string) =>
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
method: 'POST',
body: JSON.stringify({ password }),
body: JSON.stringify({ email }),
}),
claimEligibility: (email: string) =>
fetchApi<{ canClaim: boolean }>(
`/api/auth-ext/claim-eligibility?email=${encodeURIComponent(email)}`
verifyMagicLink: (token: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
method: 'POST',
body: JSON.stringify({ token }),
}),
// Password reset
requestPasswordReset: (email: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
confirmPasswordReset: (token: string, password: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
method: 'POST',
body: JSON.stringify({ token, password }),
}),
// Account claiming
requestClaimAccount: (email: string) =>
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
'/api/auth/claim-account/confirm',
{
method: 'POST',
body: JSON.stringify({ token, ...data }),
}
),
// Google Identity Services credential (ID token) sign-in
googleAuth: async (credential: string) => {
const { data, error } = await authClient.signIn.social({
provider: 'google',
idToken: { token: credential },
});
throwIfError(error, 'Google login failed');
return data;
},
// Google OAuth
googleAuth: (credential: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
method: 'POST',
body: JSON.stringify({ credential }),
}),
// Change password; other sessions are revoked so a stolen session can't
// outlive the change (this device stays signed in).
changePassword: async (currentPassword: string, newPassword: string) => {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
});
throwIfError(error, 'Failed to change password');
return { message: 'Password changed successfully' };
},
// Change password
changePassword: (currentPassword: string, newPassword: string) =>
fetchApi<{ message: string }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
}),
// Get current user
me: async (): Promise<{ user: User | null }> => {
const { data, error } = await authClient.getSession();
throwIfError(error, 'Failed to load session');
return { user: (data?.user as unknown as User) ?? null };
},
me: () => fetchApi<{ user: User }>('/api/auth/me'),
};
+17 -10
View File
@@ -1,25 +1,30 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
// Auth rides on the Better Auth httpOnly session cookie. With API_BASE unset
// (same-origin via Next rewrites) 'same-origin' sends it; a cross-origin
// API_BASE needs 'include' plus CORS credentials on the backend.
const CREDENTIALS: RequestCredentials = API_BASE ? 'include' : 'same-origin';
export interface ApiError {
error: string;
}
/** Read the stored auth token (browser only). */
export function getToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('spanglish-token') : null;
}
export async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${endpoint}`, {
credentials: CREDENTIALS,
...options,
headers,
});
@@ -31,10 +36,8 @@ export async function fetchApi<T>(
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
const error = new Error(errorMessage);
// Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so
// callers can react beyond the message text. The status lets callers tell a
// retryable server/network fault from a request that will always fail.
// callers can react beyond the message text.
(error as any).code = errorData.code;
(error as any).status = res.status;
(error as any).data = errorData;
throw error;
}
@@ -50,7 +53,11 @@ export async function fetchBlob(
endpoint: string,
fallbackFilename: string
): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(`${API_BASE}${endpoint}`, { credentials: CREDENTIALS });
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${endpoint}`, { headers });
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed');
-112
View File
@@ -1,112 +0,0 @@
import { fetchApi } from './client';
// ─── Door check-in screen API ────────────────────────────────
// Every write is idempotent on a client-generated key so the door screen can
// fire actions optimistically and retry on flaky venue wifi without ever
// creating a duplicate ticket, payment or check-in.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
export interface DoorAttendee {
ticketId: string;
firstName: string;
lastName: string | null;
fullName: string;
email: string | null;
phone: string | null;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in' | 'on_hold';
paymentStatus: 'paid' | 'unpaid' | 'comp';
isGuest: boolean;
checkedIn: boolean;
checkinAt: string | null;
checkedInBy: string | null;
bookingId: string | null;
isGroupBooking: boolean;
amountDue: number;
doorMethod: DoorPaymentMethod | null;
qrCode: string | null;
createdAt: string | null;
}
export interface DoorAttendeesResponse {
event: { id: string; title: string; price: number; currency: string; capacity: number };
attendees: DoorAttendee[];
stats: { checkedIn: number; totalActive: number; capacity: number };
}
export interface DoorCheckinRequest {
ticketId?: string;
attendee?: {
firstName: string;
lastName?: string;
phone?: string;
email?: string;
ruc?: string;
};
payment?: { method: DoorPaymentMethod; amount?: number };
entryMethod?: DoorEntryMethod;
idempotencyKey: string;
}
export interface DoorCheckinResponse {
ok: true;
action: 'checkin' | 'walkin';
attendee: DoorAttendee;
payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null;
/** 'at_capacity' — the event is full; the attendee was added anyway. */
warnings: string[];
idempotencyKey: string;
processedAt: string;
/** True when this response was replayed from an already-processed key. */
replayed?: boolean;
undone?: boolean;
}
export interface DoorMethodTotal {
count: number;
total: number;
}
export interface DoorSummary {
eventId: string;
currency: string;
price: number;
door: {
count: number;
total: number;
byMethod: Record<DoorPaymentMethod, DoorMethodTotal>;
lines: {
paymentId: string;
ticketId: string;
name: string;
method: DoorPaymentMethod;
amount: number;
paidAt: string | null;
}[];
};
presale: { count: number; total: number };
total: number;
}
export const doorApi = {
// Preloaded once per event, then searched entirely in memory.
attendees: (eventId: string) =>
fetchApi<DoorAttendeesResponse>(`/api/events/${eventId}/door-attendees`),
checkin: (eventId: string, body: DoorCheckinRequest) =>
fetchApi<DoorCheckinResponse>(`/api/events/${eventId}/door-checkin`, {
method: 'POST',
body: JSON.stringify(body),
}),
undo: (eventId: string, idempotencyKey: string) =>
fetchApi<{ ok: true; ticketId?: string; reverted?: string; alreadyUndone?: boolean }>(
`/api/events/${eventId}/door-checkin/undo`,
{ method: 'POST', body: JSON.stringify({ idempotencyKey }) }
),
summary: (eventId: string) => fetchApi<DoorSummary>(`/api/events/${eventId}/door-summary`),
};
+2 -6
View File
@@ -2,15 +2,11 @@ import { fetchApi } from './client';
import type { Event } from './types';
export const eventsApi = {
getAll: (params?: { status?: string; upcoming?: boolean; page?: number; pageSize?: number }) => {
getAll: (params?: { status?: string; upcoming?: boolean }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.upcoming) query.set('upcoming', 'true');
// Passing page/pageSize switches the endpoint into paginated mode, which also
// returns `total`; without them the full list comes back as before.
if (params?.page) query.set('page', String(params.page));
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
return fetchApi<{ events: Event[]; total?: number }>(`/api/events?${query}`);
return fetchApi<{ events: Event[] }>(`/api/events?${query}`);
},
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
-11
View File
@@ -4,17 +4,6 @@ export * from './types';
export { eventsApi } from './events';
export { ticketsApi } from './tickets';
export { doorApi, DOOR_PAYMENT_METHODS } from './door';
export type {
DoorAttendee,
DoorAttendeesResponse,
DoorCheckinRequest,
DoorCheckinResponse,
DoorEntryMethod,
DoorMethodTotal,
DoorPaymentMethod,
DoorSummary,
} from './door';
export { contactsApi } from './contacts';
export { usersApi } from './users';
export { paymentsApi } from './payments';
+4 -3
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE } from './client';
import { fetchApi, API_BASE, getToken } from './client';
import type { Media } from './types';
export const mediaApi = {
@@ -11,15 +11,16 @@ export const mediaApi = {
},
upload: async (file: File, relatedId?: string, relatedType?: string) => {
const token = getToken();
const formData = new FormData();
formData.append('file', file);
if (relatedId) formData.append('relatedId', relatedId);
if (relatedType) formData.append('relatedType', relatedType);
// Auth rides on the session cookie
const res = await fetch(`${API_BASE}/api/media/upload`, {
method: 'POST',
credentials: API_BASE ? 'include' : 'same-origin',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
+5 -20
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE } from './client';
import { fetchApi, API_BASE, getToken } from './client';
// Client for the standalone photo-api Go service (photo-api/), reachable
// under /api/photos via the Next rewrite (dev) or nginx (prod).
@@ -41,8 +41,6 @@ export interface Photo {
originalFilename?: string;
contentType: string;
sizeBytes: number;
/** Size of the preview variant; absent until the server has measured it. */
previewSizeBytes?: number;
width?: number;
height?: number;
takenAt?: string;
@@ -53,19 +51,7 @@ export interface Photo {
thumb?: string;
preview?: string;
original: string;
/**
* Download endpoints. Unlike `preview`/`original` these always stream
* same-origin instead of redirecting to S3, so fetch() can read them
* into a Blob (needed by the mobile share sheet) without CORS.
* `download` serves the preview and is what the lightbox renders, so
* saving it hits the HTTP cache instead of the network.
*/
download?: string;
downloadOriginal: string;
};
// Upload responses only: these bytes were already in the gallery, so nothing
// was stored and the rest of this object describes the existing photo.
duplicate?: boolean;
}
export interface CreateGalleryInput {
@@ -108,13 +94,13 @@ export const photosApi = {
}),
uploadPhoto: async (galleryId: string, file: File) => {
const token = getToken();
const formData = new FormData();
formData.append('files', file);
// Auth rides on the session cookie (sent same-origin automatically)
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
method: 'POST',
credentials: API_BASE ? 'include' : 'same-origin',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
@@ -136,9 +122,8 @@ export const photosApi = {
new Promise<{ photos: Photo[] }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
// Auth rides on the session cookie; XHR sends same-origin cookies by
// default, withCredentials is only needed for a cross-origin API_BASE.
if (API_BASE) xhr.withCredentials = true;
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
};
+3 -4
View File
@@ -106,12 +106,11 @@ export const ticketsApi = {
}),
// Unified add-attendee endpoint behind the single Add Ticket modal
// (paid = confirmation + QR, door = cash taken at the door, unpaid = pay link +
// door collection, guest = free comp)
// (paid = confirmation + QR, unpaid = pay link + door collection, guest = free comp)
adminAdd: (data: {
eventId: string;
type: 'paid' | 'door' | 'unpaid' | 'guest';
firstName?: string;
type: 'paid' | 'unpaid' | 'guest';
firstName: string;
lastName?: string;
email?: string;
phone?: string;
-3
View File
@@ -439,9 +439,6 @@ export interface UserSession {
ipAddress?: string;
lastActiveAt: string;
createdAt: string;
expiresAt?: string;
/** True for the session backing the current request. */
current?: boolean;
}
export interface DashboardSummary {
-22
View File
@@ -1,22 +0,0 @@
import { createAuthClient } from 'better-auth/react';
import { magicLinkClient, adminClient, inferAdditionalFields } from 'better-auth/client/plugins';
// Better Auth client. Sessions are httpOnly cookies set by the backend; with
// NEXT_PUBLIC_API_URL unset everything is same-origin through the Next.js
// rewrites (see next.config.js), so cookies flow automatically.
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || '',
plugins: [
magicLinkClient(),
adminClient(),
inferAdditionalFields({
user: {
phone: { type: 'string', required: false },
languagePreference: { type: 'string', required: false },
rucNumber: { type: 'string', required: false },
isClaimed: { type: 'boolean', required: false },
accountStatus: { type: 'string', required: false },
},
}),
],
});
-56
View File
@@ -1,56 +0,0 @@
/**
* Post-authentication navigation.
*
* Navigation uses a full page load rather than `router.push`. The session is an httpOnly
* cookie, and only a top-level request lets the middleware guard in `middleware.ts` see it
* and re-render the header and the destination together.
*
* A marker in sessionStorage records where we last sent someone, and when. If the
* middleware cannot see the session cookie it redirects straight back to `/login`, and
* without the marker the login page would immediately navigate again an endless reload
* loop. A React ref cannot do this job: a full page load resets component state.
*/
const ATTEMPT_KEY = 'spanglish.auth-redirect-attempt';
// A real bounce comes back within a few hundred milliseconds (it is a server redirect).
// An older marker means an unrelated later visit to /login, not a failed redirect.
const ATTEMPT_TTL_MS = 30_000;
export function markRedirectAttempt(target: string): void {
try {
sessionStorage.setItem(ATTEMPT_KEY, JSON.stringify({ target, at: Date.now() }));
} catch {
/* storage disabled (private mode): the loop guard degrades, navigation still works */
}
}
export function clearRedirectAttempt(): void {
try {
sessionStorage.removeItem(ATTEMPT_KEY);
} catch {
/* ignore */
}
}
/** True when we just sent the user to `target` and are back on the login page instead. */
export function didRedirectBounce(target: string): boolean {
try {
const raw = sessionStorage.getItem(ATTEMPT_KEY);
if (!raw) return false;
const { target: attempted, at } = JSON.parse(raw) as { target?: string; at?: number };
return (
attempted === target && typeof at === 'number' && Date.now() - at < ATTEMPT_TTL_MS
);
} catch {
return false;
}
}
/**
* Records the attempt, then navigates. Callers should keep their pending/loading UI on
* screen: this never resolves, the page is replaced.
*/
export function redirectAfterAuth(target: string): void {
markRedirectAttempt(target);
window.location.assign(target);
}
+7 -24
View File
@@ -3,35 +3,18 @@ import { NextRequest, NextResponse } from 'next/server';
/**
* Defense-in-depth guard for authenticated areas.
*
* Auth is a Better Auth httpOnly session cookie, which IS visible to this
* server-side middleware (unlike client JS). The API remains the authoritative
* gate cookie presence is not validated here; this only keeps clearly
* unauthenticated visitors from loading the admin/dashboard JS shell.
* Auth tokens live in localStorage (not readable here), so we rely on a lightweight
* `spanglish-auth` cookie set alongside login. The API remains the authoritative gate;
* this only keeps unauthenticated visitors from loading the admin/dashboard JS shell.
*/
const SESSION_COOKIES = [
'__Secure-spanglish.session_token', // production (useSecureCookies)
'spanglish.session_token', // development
];
// Matched as a fallback so a change to Better Auth's `advanced.cookiePrefix` cannot
// silently lock every user out of these routes.
const SESSION_COOKIE_SUFFIX = '.session_token';
function hasSessionCookie(request: NextRequest): boolean {
if (SESSION_COOKIES.some((name) => !!request.cookies.get(name)?.value)) return true;
return request.cookies
.getAll()
.some((cookie) => cookie.name.endsWith(SESSION_COOKIE_SUFFIX) && !!cookie.value);
}
export function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const { pathname } = request.nextUrl;
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
if (!hasSessionCookie(request)) {
const hasAuthCookie = request.cookies.get('spanglish-auth')?.value === '1';
if (!hasAuthCookie) {
const loginUrl = new URL('/login', request.url);
// Keep the query string, so a bounced /admin/photos?page=3 resumes where it was.
loginUrl.searchParams.set('redirect', `${pathname}${search}`);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
}
-12
View File
@@ -37,18 +37,6 @@ module.exports = {
'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'card-hover': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
},
// Entrance for the mobile save sheet (components/gallery/SaveSheet).
keyframes: {
'sheet-fade': { from: { opacity: '0' }, to: { opacity: '1' } },
'sheet-rise': {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
},
animation: {
'sheet-fade': 'sheet-fade 150ms ease-out',
'sheet-rise': 'sheet-rise 200ms ease-out',
},
},
},
plugins: [],
-12
View File
@@ -14,10 +14,6 @@
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
"test:photos": "cd photo-api && go test ./...",
"migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate",
"sync:photos": "cd photo-api && go run ./cmd/photo-api sync",
"sync:photos:to-s3": "cd photo-api && go run ./cmd/photo-api sync to-s3",
"sync:photos:to-local": "cd photo-api && go run ./cmd/photo-api sync to-local",
"backfill:photos:checksums": "cd photo-api && go run ./cmd/photo-api backfill-checksums",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"start:backend": "npm run start --workspace=backend",
"start:frontend": "npm run start --workspace=frontend",
@@ -36,13 +32,5 @@
},
"dependencies": {
"next": "^16.2.10"
},
"allowScripts": {
"esbuild@0.18.20": true,
"esbuild@0.25.12": true,
"esbuild@0.27.2": true,
"better-sqlite3@12.11.1": true,
"sharp@0.34.5": true,
"argon2@0.44.0": true
}
}
+5 -20
View File
@@ -16,32 +16,17 @@ DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
# DB_TYPE=sqlite
# DATABASE_URL=../backend/data/spanglish.db
# Secret for gallery image view tokens (HMAC). Any strong random value; does
# not need to match any backend secret. Rotating it invalidates outstanding
# image URLs for at most ~1 hour (they are re-minted per page load).
PHOTO_VIEW_SECRET=8b69468724b730dddecba3d04717cf44e6dc5b808773e7e60156d38875f0f6cd
# DEPRECATED: fallback for PHOTO_VIEW_SECRET during the Better Auth migration
# (user auth now validates Better Auth sessions from the shared database, not
# JWTs). Set PHOTO_VIEW_SECRET and remove this.
# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the
# same HS256 tokens the backend issues.
JWT_SECRET=
# Public site origin, used to build share links and allow dev CORS
FRONTEND_URL=https://spanglishcommunity.com
# Photo storage. Which backend serves requests:
# auto (default) S3 when S3_ENDPOINT + S3_BUCKET are set, local otherwise
# — the backend's convention (backend/src/lib/storage.ts)
# local always local disk, even with the S3 settings filled in
# s3 always S3
# Keep both sides filled in and flip this one line to switch backends. Both
# are also required by the library sync (`npm run sync:photos:to-s3` /
# `:to-local`), which copies every photo from one backend to the other.
STORAGE_BACKEND=auto
STORAGE_PATH=./data/photos
# Photo storage. Local disk by default; setting BOTH S3_ENDPOINT and
# S3_BUCKET switches to S3 (same convention as the backend's media storage).
# Use a photos-specific bucket — do not reuse the backend's media bucket.
STORAGE_PATH=./data/photos
#S3_ENDPOINT=
#S3_REGION=auto
#S3_BUCKET=spanglish-photos
+2 -11
View File
@@ -1,11 +1,11 @@
.PHONY: start build test migrate sync-to-s3 sync-to-local backfill-checksums clean help
.PHONY: start build test migrate clean help
BINARY := bin/photo-api
CMD := ./cmd/photo-api
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-19s %s\n", $$1, $$2}'
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
start: ## Run the photo-api server (go run)
go run $(CMD)
@@ -19,14 +19,5 @@ test: ## Run all Go tests
migrate: ## Apply pending photos_* migrations
go run $(CMD) migrate
sync-to-s3: ## Copy the photo library from local disk to S3 (needs both in .env)
go run $(CMD) sync to-s3
sync-to-local: ## Copy the photo library from S3 back to local disk
go run $(CMD) sync to-local
backfill-checksums: ## Hash photos uploaded before duplicate detection existed
go run $(CMD) backfill-checksums
clean: ## Remove built binary
rm -rf bin
+1 -1
View File
@@ -131,7 +131,7 @@ All routes under `/api/photos`. Auth = `Authorization: Bearer <existing JWT>`. "
| `GET /api/photos/galleries/:id` | Full gallery detail incl. photos in position order with per-variant URLs and processing status. | `{ gallery, photos: [...] }` |
| `PATCH /api/photos/galleries/:id` | Update title/description/eventId/visibility/coverPhotoId. | `{ gallery }` |
| `DELETE /api/photos/galleries/:id` | Delete gallery + photos + stored objects. | `{ message }` |
| `POST /api/photos/galleries/:id/photos` | Multipart upload, field `files` (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit `415 { error: "HEIC is not supported, please upload JPEG" }` for HEIC). Stores original, inserts row `status='queued'`, appends at end position. Hashes the bytes into `checksum`: if the gallery already holds them, nothing is stored and the existing photo is echoed with `duplicate: true` (the batch continues, no position consumed). | `201 { photos: [...] }` |
| `POST /api/photos/galleries/:id/photos` | Multipart upload, field `files` (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit `415 { error: "HEIC is not supported, please upload JPEG" }` for HEIC). Stores original, inserts row `status='queued'`, appends at end position. | `201 { photos: [...] }` |
| `PATCH /api/photos/galleries/:id/order` | Body `{ photoIds: [uuid,...] }` — full ordering; positions rewritten in one transaction. | `{ message }` |
| `DELETE /api/photos/photos/:photoId` | Delete one photo + its objects; compacts positions. | `{ message }` |
| `POST /api/photos/galleries/:id/share-token` | Rotate share token (invalidate old links). | `{ gallery }` |

Some files were not shown because too many files have changed in this diff Show More