Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie sessions, validated against the database on every request so revocation, bans and role changes take effect immediately. Backend: - betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and the admin plugin; auth-schema.ts maps Better Auth's models onto the existing `users` table so user IDs and their foreign keys survive intact. - routes/auth.ts is gone; Better Auth serves the standard endpoints and authExt.ts carries the flows it doesn't cover. - auth.ts shrinks to session resolution and helpers; sessions/revocation in dashboard.ts now read and delete `auth_sessions` rows directly. - Schema adds the Better Auth core + admin columns (email_verified, image, banned, ban_reason, ban_expires), with migrations and tests. - rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES. - passwordPolicy.ts centralises password validation. - Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible with Better Auth. Frontend: - auth-client.ts plus a reworked AuthContext and api/client.ts move to cookie-based sessions; no more bearer tokens in requests or middleware. photo-api: - Validate Better Auth session cookies against the shared auth_sessions table instead of verifying JWTs; JWT_SECRET is no longer needed for user auth, and PHOTO_VIEW_SECRET now signs gallery view tokens. BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the deprecated JWT_SECRET stays only as the photo-api view-token fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
633 lines
17 KiB
TypeScript
633 lines
17 KiB
TypeScript
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 { 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';
|
|
|
|
const dashboard = new Hono();
|
|
|
|
// Apply authentication to all routes
|
|
dashboard.use('*', requireAuth());
|
|
|
|
// ==================== Profile Routes ====================
|
|
|
|
const updateProfileSchema = z.object({
|
|
name: z.string().min(2).optional(),
|
|
phone: z.string().optional(),
|
|
languagePreference: z.enum(['en', 'es']).optional(),
|
|
rucNumber: z.string().max(15).optional(),
|
|
});
|
|
|
|
// Get user profile
|
|
dashboard.get('/profile', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
|
|
// Get membership duration
|
|
const createdDate = new Date(user.createdAt);
|
|
const now = new Date();
|
|
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
const hasPassword = !!(await getUserPasswordHash(user.id));
|
|
|
|
return c.json({
|
|
profile: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
phone: user.phone,
|
|
languagePreference: user.languagePreference,
|
|
rucNumber: user.rucNumber,
|
|
isClaimed: user.isClaimed,
|
|
accountStatus: user.accountStatus,
|
|
hasPassword,
|
|
hasGoogleLinked: await hasGoogleAccount(user.id),
|
|
memberSince: user.createdAt,
|
|
membershipDays,
|
|
createdAt: user.createdAt,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Update profile
|
|
dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
const data = c.req.valid('json');
|
|
const now = getNow();
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
...data,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((users as any).id, user.id));
|
|
|
|
const updatedUser = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(users)
|
|
.where(eq((users as any).id, user.id))
|
|
);
|
|
|
|
return c.json({
|
|
profile: {
|
|
id: updatedUser.id,
|
|
email: updatedUser.email,
|
|
name: updatedUser.name,
|
|
phone: updatedUser.phone,
|
|
languagePreference: updatedUser.languagePreference,
|
|
rucNumber: updatedUser.rucNumber,
|
|
},
|
|
message: 'Profile updated successfully',
|
|
});
|
|
});
|
|
|
|
// ==================== Tickets Routes ====================
|
|
|
|
// Get user's tickets
|
|
dashboard.get('/tickets', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
|
|
const userTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).userId, user.id))
|
|
.orderBy(desc((tickets as any).createdAt))
|
|
);
|
|
|
|
// Batch-fetch related events, payments, and invoices (avoids N+1 per ticket).
|
|
const eventIds = [...new Set(userTickets.map((t: any) => t.eventId).filter(Boolean))];
|
|
const ticketIds = userTickets.map((t: any) => t.id);
|
|
|
|
const eventRows = eventIds.length
|
|
? await dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds)))
|
|
: [];
|
|
const paymentRows = ticketIds.length
|
|
? await dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)))
|
|
: [];
|
|
|
|
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
|
|
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
|
|
|
|
const paidPaymentIds = paymentRows.filter((p: any) => p.status === 'paid').map((p: any) => p.id);
|
|
const invoiceRows = paidPaymentIds.length
|
|
? await dbAll<any>((db as any).select().from(invoices).where(inArray((invoices as any).paymentId, paidPaymentIds)))
|
|
: [];
|
|
const invoicesByPaymentId = new Map(invoiceRows.map((inv: any) => [inv.paymentId, inv]));
|
|
|
|
const ticketsWithEvents = userTickets.map((ticket: any) => {
|
|
const event = eventsById.get(ticket.eventId);
|
|
const payment = paymentsByTicketId.get(ticket.id);
|
|
const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null;
|
|
|
|
return {
|
|
...ticket,
|
|
event: event ? {
|
|
id: event.id,
|
|
title: event.title,
|
|
titleEs: event.titleEs,
|
|
startDatetime: event.startDatetime,
|
|
endDatetime: event.endDatetime,
|
|
location: event.location,
|
|
locationUrl: event.locationUrl,
|
|
price: event.price,
|
|
currency: event.currency,
|
|
status: event.status,
|
|
bannerUrl: event.bannerUrl,
|
|
} : null,
|
|
payment: payment ? {
|
|
id: payment.id,
|
|
provider: payment.provider,
|
|
amount: payment.amount,
|
|
currency: payment.currency,
|
|
status: payment.status,
|
|
paidAt: payment.paidAt,
|
|
} : null,
|
|
invoice: invoice ? {
|
|
id: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
pdfUrl: invoice.pdfUrl,
|
|
createdAt: invoice.createdAt,
|
|
} : null,
|
|
};
|
|
});
|
|
|
|
return c.json({ tickets: ticketsWithEvents });
|
|
});
|
|
|
|
// Get single ticket detail
|
|
dashboard.get('/tickets/:id', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
const ticketId = c.req.param('id');
|
|
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(
|
|
and(
|
|
eq((tickets as any).id, ticketId),
|
|
eq((tickets as any).userId, user.id)
|
|
)
|
|
)
|
|
);
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
const event = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
const payment = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).ticketId, ticket.id))
|
|
);
|
|
|
|
let invoice = null;
|
|
if (payment && payment.status === 'paid') {
|
|
invoice = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(invoices)
|
|
.where(eq((invoices as any).paymentId, payment.id))
|
|
);
|
|
}
|
|
|
|
return c.json({
|
|
ticket: {
|
|
...ticket,
|
|
event,
|
|
payment,
|
|
invoice,
|
|
},
|
|
});
|
|
});
|
|
|
|
// ==================== Next Event Route ====================
|
|
|
|
// Get next upcoming event for user
|
|
dashboard.get('/next-event', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
const now = getNow();
|
|
|
|
// Get user's tickets for upcoming events
|
|
const userTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).userId, user.id))
|
|
);
|
|
|
|
if (userTickets.length === 0) {
|
|
return c.json({ nextEvent: null });
|
|
}
|
|
|
|
// Find the next upcoming event
|
|
let nextEvent = null;
|
|
let nextTicket = null;
|
|
let nextPayment = null;
|
|
|
|
for (const ticket of userTickets) {
|
|
if (ticket.status === 'cancelled') continue;
|
|
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
if (!event) continue;
|
|
|
|
// Check if event is in the future
|
|
if (new Date(event.startDatetime) > new Date()) {
|
|
if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) {
|
|
nextEvent = event;
|
|
nextTicket = ticket;
|
|
nextPayment = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).ticketId, ticket.id))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!nextEvent) {
|
|
return c.json({ nextEvent: null });
|
|
}
|
|
|
|
return c.json({
|
|
nextEvent: {
|
|
event: nextEvent,
|
|
ticket: nextTicket,
|
|
payment: nextPayment,
|
|
},
|
|
});
|
|
});
|
|
|
|
// ==================== Payments & Invoices Routes ====================
|
|
|
|
// Get payment history
|
|
dashboard.get('/payments', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
|
|
// Get all user's tickets first
|
|
const userTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).userId, user.id))
|
|
);
|
|
|
|
const ticketIds = userTickets.map((t: any) => t.id);
|
|
|
|
if (ticketIds.length === 0) {
|
|
return c.json({ payments: [] });
|
|
}
|
|
|
|
// Get all payments for user's tickets
|
|
const allPayments = [];
|
|
for (const ticketId of ticketIds) {
|
|
const ticketPayments = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).ticketId, ticketId))
|
|
);
|
|
|
|
for (const payment of ticketPayments) {
|
|
const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
|
|
const event = ticket
|
|
? await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
)
|
|
: null;
|
|
|
|
let invoice: any = null;
|
|
if (payment.status === 'paid') {
|
|
invoice = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(invoices)
|
|
.where(eq((invoices as any).paymentId, payment.id))
|
|
);
|
|
}
|
|
|
|
allPayments.push({
|
|
...payment,
|
|
ticket: ticket ? {
|
|
id: ticket.id,
|
|
attendeeFirstName: ticket.attendeeFirstName,
|
|
attendeeLastName: ticket.attendeeLastName,
|
|
status: ticket.status,
|
|
} : null,
|
|
event: event ? {
|
|
id: event.id,
|
|
title: event.title,
|
|
titleEs: event.titleEs,
|
|
startDatetime: event.startDatetime,
|
|
} : null,
|
|
invoice: invoice ? {
|
|
id: invoice.id,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
pdfUrl: invoice.pdfUrl,
|
|
} : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by createdAt desc
|
|
allPayments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
|
|
return c.json({ payments: allPayments });
|
|
});
|
|
|
|
// Get invoices
|
|
dashboard.get('/invoices', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
|
|
const userInvoices = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(invoices)
|
|
.where(eq((invoices as any).userId, user.id))
|
|
.orderBy(desc((invoices as any).createdAt))
|
|
);
|
|
|
|
// Get payment and event details for each invoice
|
|
const invoicesWithDetails = await Promise.all(
|
|
userInvoices.map(async (invoice: any) => {
|
|
const payment = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, invoice.paymentId))
|
|
);
|
|
|
|
let event: any = null;
|
|
if (payment) {
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
|
|
if (ticket) {
|
|
event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...invoice,
|
|
event: event ? {
|
|
id: event.id,
|
|
title: event.title,
|
|
titleEs: event.titleEs,
|
|
startDatetime: event.startDatetime,
|
|
} : null,
|
|
};
|
|
})
|
|
);
|
|
|
|
return c.json({ invoices: invoicesWithDetails });
|
|
});
|
|
|
|
// ==================== 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.
|
|
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))
|
|
);
|
|
|
|
return c.json({
|
|
sessions: sessions.map((s: any) => ({
|
|
id: s.id,
|
|
userAgent: s.userAgent,
|
|
ipAddress: s.ipAddress,
|
|
lastActiveAt: s.updatedAt,
|
|
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).
|
|
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)
|
|
)
|
|
);
|
|
|
|
return c.json({ message: 'Session revoked' });
|
|
});
|
|
|
|
// Revoke all other sessions (logout everywhere else); the current session
|
|
// stays valid so this device remains signed in.
|
|
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}`
|
|
)
|
|
);
|
|
|
|
return c.json({ message: 'All other sessions revoked.' });
|
|
});
|
|
|
|
// Set password (for users without one)
|
|
const setPasswordSchema = z.object({
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
const { password } = c.req.valid('json');
|
|
|
|
// Check if user already has a password
|
|
if (await getUserPasswordHash(user.id)) {
|
|
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);
|
|
}
|
|
|
|
return c.json({ message: 'Password set successfully' });
|
|
});
|
|
|
|
// Unlink Google account (only if password is set)
|
|
dashboard.post('/unlink-google', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
|
|
if (!(await hasGoogleAccount(user.id))) {
|
|
return c.json({ error: 'Google account not linked' }, 400);
|
|
}
|
|
|
|
if (!(await getUserPasswordHash(user.id))) {
|
|
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')
|
|
)
|
|
);
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set({ updatedAt: getNow() })
|
|
.where(eq((users as any).id, user.id));
|
|
|
|
return c.json({ message: 'Google account unlinked' });
|
|
});
|
|
|
|
// ==================== Dashboard Summary Route ====================
|
|
|
|
// Get dashboard summary (welcome panel data)
|
|
dashboard.get('/summary', async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
const now = new Date();
|
|
|
|
// Get membership duration
|
|
const createdDate = new Date(user.createdAt);
|
|
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
// Get ticket count
|
|
const userTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).userId, user.id))
|
|
);
|
|
|
|
const totalTickets = userTickets.length;
|
|
const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length;
|
|
const upcomingTickets = [];
|
|
|
|
for (const ticket of userTickets) {
|
|
if (ticket.status === 'cancelled') continue;
|
|
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
if (event && new Date(event.startDatetime) > now) {
|
|
upcomingTickets.push({ ticket, event });
|
|
}
|
|
}
|
|
|
|
// Get pending payments count
|
|
const ticketIds = userTickets.map((t: any) => t.id);
|
|
let pendingPayments = 0;
|
|
|
|
for (const ticketId of ticketIds) {
|
|
const payment = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(
|
|
and(
|
|
eq((payments as any).ticketId, ticketId),
|
|
eq((payments as any).status, 'pending_approval')
|
|
)
|
|
)
|
|
);
|
|
|
|
if (payment) pendingPayments++;
|
|
}
|
|
|
|
return c.json({
|
|
summary: {
|
|
user: {
|
|
name: user.name,
|
|
email: user.email,
|
|
accountStatus: user.accountStatus,
|
|
memberSince: user.createdAt,
|
|
membershipDays,
|
|
},
|
|
stats: {
|
|
totalTickets,
|
|
confirmedTickets,
|
|
upcomingEvents: upcomingTickets.length,
|
|
pendingPayments,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
export default dashboard;
|