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>
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
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 {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: string;
|
|
}
|
|
|
|
const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
|
|
|
|
const updateUserSchema = z.object({
|
|
name: z.string().min(2).optional(),
|
|
email: z.string().email().optional(),
|
|
phone: z.string().optional(),
|
|
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
|
|
languagePreference: z.enum(['en', 'es']).optional(),
|
|
accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(),
|
|
});
|
|
|
|
// Get all users (admin only)
|
|
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
|
const role = c.req.query('role');
|
|
const search = c.req.query('search');
|
|
const accountStatus = c.req.query('accountStatus');
|
|
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
|
|
const registeredAfter = c.req.query('registeredAfter');
|
|
const registeredBefore = c.req.query('registeredBefore');
|
|
const eventId = c.req.query('eventId');
|
|
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
|
|
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
|
|
|
|
const conditions: any[] = [];
|
|
if (role) conditions.push(eq((users as any).role, role));
|
|
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
|
|
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
|
|
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
|
|
if (search) {
|
|
const like = `%${search.toLowerCase()}%`;
|
|
conditions.push(sql`(
|
|
LOWER(${(users as any).name}) LIKE ${like}
|
|
OR LOWER(${(users as any).email}) LIKE ${like}
|
|
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
|
|
)`);
|
|
}
|
|
if (hasBookings === 'yes') {
|
|
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
|
} else if (hasBookings === 'no') {
|
|
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
|
}
|
|
if (eventId) {
|
|
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
|
|
}
|
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
const totalQuery = whereClause
|
|
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
|
|
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
|
|
const totalRow = await dbGet<any>(totalQuery);
|
|
|
|
let query = (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,
|
|
rucNumber: (users as any).rucNumber,
|
|
accountStatus: (users as any).accountStatus,
|
|
createdAt: (users as any).createdAt,
|
|
}).from(users);
|
|
|
|
if (whereClause) {
|
|
query = query.where(whereClause);
|
|
}
|
|
|
|
const result = await dbAll(
|
|
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
|
|
);
|
|
|
|
return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize });
|
|
});
|
|
|
|
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
|
|
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
|
const totalUsers = await dbGet<any>(
|
|
(db as any)
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(users)
|
|
);
|
|
|
|
const adminCount = await dbGet<any>(
|
|
(db as any)
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(users)
|
|
.where(eq((users as any).role, 'admin'))
|
|
);
|
|
|
|
return c.json({
|
|
stats: {
|
|
total: totalUsers?.count || 0,
|
|
admins: adminCount?.count || 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Get user by ID (admin or self)
|
|
usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
|
|
const id = c.req.param('id');
|
|
const currentUser = c.get('user');
|
|
|
|
// Users can only view their own profile unless admin
|
|
if (currentUser.role !== 'admin' && currentUser.id !== id) {
|
|
return c.json({ error: 'Forbidden' }, 403);
|
|
}
|
|
|
|
const user = await dbGet(
|
|
(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,
|
|
rucNumber: (users as any).rucNumber,
|
|
accountStatus: (users as any).accountStatus,
|
|
createdAt: (users as any).createdAt,
|
|
})
|
|
.from(users)
|
|
.where(eq((users as any).id, id))
|
|
);
|
|
|
|
if (!user) {
|
|
return c.json({ error: 'User not found' }, 404);
|
|
}
|
|
|
|
return c.json({ user });
|
|
});
|
|
|
|
// Update user (admin or self)
|
|
usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), zValidator('json', updateUserSchema), async (c) => {
|
|
const id = c.req.param('id');
|
|
const data = c.req.valid('json');
|
|
const currentUser = c.get('user');
|
|
|
|
// Users can only update their own profile unless admin
|
|
if (currentUser.role !== 'admin' && currentUser.id !== id) {
|
|
return c.json({ error: 'Forbidden' }, 403);
|
|
}
|
|
|
|
// Only admin can change roles, email, and account status
|
|
if (data.role && currentUser.role !== 'admin') {
|
|
delete data.role;
|
|
}
|
|
if (data.email && currentUser.role !== 'admin') {
|
|
delete data.email;
|
|
}
|
|
if (data.accountStatus && currentUser.role !== 'admin') {
|
|
delete data.accountStatus;
|
|
}
|
|
|
|
const existing = await dbGet(
|
|
(db as any).select().from(users).where(eq((users as any).id, id))
|
|
);
|
|
if (!existing) {
|
|
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() })
|
|
.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({
|
|
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,
|
|
rucNumber: (users as any).rucNumber,
|
|
accountStatus: (users as any).accountStatus,
|
|
createdAt: (users as any).createdAt,
|
|
})
|
|
.from(users)
|
|
.where(eq((users as any).id, id))
|
|
);
|
|
|
|
return c.json({ user: updated });
|
|
});
|
|
|
|
// Get user's ticket history
|
|
usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
|
|
const id = c.req.param('id');
|
|
const currentUser = c.get('user');
|
|
|
|
// Users can only view their own history unless admin/organizer
|
|
if (!['admin', 'organizer'].includes(currentUser.role) && currentUser.id !== id) {
|
|
return c.json({ error: 'Forbidden' }, 403);
|
|
}
|
|
|
|
const userTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).userId, id))
|
|
.orderBy(desc((tickets as any).createdAt))
|
|
);
|
|
|
|
// Get event details for each ticket
|
|
const history = await Promise.all(
|
|
userTickets.map(async (ticket: any) => {
|
|
const event = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
return {
|
|
...ticket,
|
|
event,
|
|
};
|
|
})
|
|
);
|
|
|
|
return c.json({ history });
|
|
});
|
|
|
|
// Delete user (admin only)
|
|
usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
|
const id = c.req.param('id');
|
|
const currentUser = c.get('user');
|
|
|
|
// Prevent self-deletion
|
|
if (currentUser.id === id) {
|
|
return c.json({ error: 'Cannot delete your own account' }, 400);
|
|
}
|
|
|
|
const existing = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, id))
|
|
);
|
|
if (!existing) {
|
|
return c.json({ error: 'User not found' }, 404);
|
|
}
|
|
|
|
// Prevent deleting admin users
|
|
if (existing.role === 'admin') {
|
|
return c.json({ error: 'Cannot delete admin users' }, 400);
|
|
}
|
|
|
|
try {
|
|
// Get all tickets for this user
|
|
const userTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).userId, id))
|
|
);
|
|
|
|
// Delete invoices associated with user's tickets (invoices reference payments which reference tickets)
|
|
for (const ticket of userTickets) {
|
|
// Get payments for this ticket
|
|
const ticketPayments = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).ticketId, ticket.id))
|
|
);
|
|
|
|
// Delete invoices for each payment
|
|
for (const payment of ticketPayments) {
|
|
await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id));
|
|
}
|
|
|
|
// Delete payments for this ticket
|
|
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
|
|
}
|
|
|
|
// Delete invoices directly associated with the user (if any)
|
|
await (db as any).delete(invoices).where(eq((invoices as any).userId, id));
|
|
|
|
// Delete user's tickets
|
|
await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
|
|
|
|
// Delete magic link tokens for the user
|
|
await (db as any).delete(magicLinkTokens).where(eq((magicLinkTokens as any).userId, id));
|
|
|
|
// Delete user sessions
|
|
await (db as any).delete(userSessions).where(eq((userSessions as any).userId, id));
|
|
|
|
// Set userId to null in audit_logs (nullable reference)
|
|
await (db as any)
|
|
.update(auditLogs)
|
|
.set({ userId: null })
|
|
.where(eq((auditLogs as any).userId, id));
|
|
|
|
// Set sentBy to null in email_logs (nullable reference)
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({ sentBy: null })
|
|
.where(eq((emailLogs as any).sentBy, id));
|
|
|
|
// Set updatedBy to null in payment_options (nullable reference)
|
|
await (db as any)
|
|
.update(paymentOptions)
|
|
.set({ updatedBy: null })
|
|
.where(eq((paymentOptions as any).updatedBy, id));
|
|
|
|
// Set updatedBy to null in legal_pages (nullable reference)
|
|
await (db as any)
|
|
.update(legalPages)
|
|
.set({ updatedBy: null })
|
|
.where(eq((legalPages as any).updatedBy, id));
|
|
|
|
// Set updatedBy to null in site_settings (nullable reference)
|
|
await (db as any)
|
|
.update(siteSettings)
|
|
.set({ updatedBy: null })
|
|
.where(eq((siteSettings as any).updatedBy, id));
|
|
|
|
// Clear checkedInByAdminId references in tickets
|
|
await (db as any)
|
|
.update(tickets)
|
|
.set({ checkedInByAdminId: null })
|
|
.where(eq((tickets as any).checkedInByAdminId, id));
|
|
|
|
// Delete the user
|
|
await (db as any).delete(users).where(eq((users as any).id, id));
|
|
|
|
return c.json({ message: 'User deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Error deleting user:', error);
|
|
return c.json({ error: 'Failed to delete user. They may have related records.' }, 500);
|
|
}
|
|
});
|
|
|
|
export default usersRouter;
|