From c6d9f249ce7907aaea334eb6c64d3dbf41d11632 Mon Sep 17 00:00:00 2001 From: Hammer Date: Wed, 28 Jan 2026 21:39:31 +0000 Subject: [PATCH] feat: admin system with invite-only registration --- src/db/schema.ts | 14 +++++ src/index.ts | 26 +++++++++ src/lib/auth.ts | 9 +++ src/routes/admin.ts | 128 +++++++++++++++++++++++++++++++++++++++++++ src/routes/invite.ts | 106 +++++++++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+) create mode 100644 src/routes/admin.ts create mode 100644 src/routes/invite.ts diff --git a/src/db/schema.ts b/src/db/schema.ts index b9deaa2..26f442b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -8,10 +8,24 @@ export const users = pgTable('users', { name: text('name').notNull(), emailVerified: boolean('email_verified').default(false), image: text('image'), + role: text('role').default('user'), // 'admin' | 'user' createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); +// Invites table +export const invites = pgTable('invites', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull(), + name: text('name').notNull(), + role: text('role').default('user').notNull(), + token: text('token').notNull().unique(), + invitedBy: text('invited_by').references(() => users.id, { onDelete: 'set null' }), + status: text('status').default('pending').notNull(), // 'pending' | 'accepted' | 'expired' + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + // User profile (additional settings beyond BetterAuth) export const userProfiles = pgTable('user_profiles', { id: uuid('id').primaryKey().defaultRandom(), diff --git a/src/index.ts b/src/index.ts index f0faf90..47a9cc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,11 @@ import { clientRoutes } from './routes/clients'; import { emailRoutes } from './routes/emails'; import { eventRoutes } from './routes/events'; import { profileRoutes } from './routes/profile'; +import { adminRoutes } from './routes/admin'; +import { inviteRoutes } from './routes/invite'; +import { db } from './db'; +import { users } from './db/schema'; +import { eq } from 'drizzle-orm'; import type { User } from './lib/auth'; const app = new Elysia() @@ -22,6 +27,9 @@ const app = new Elysia() .all('/api/auth/*', async ({ request }) => { return auth.handler(request); }) + + // Public invite routes (no auth required) + .use(inviteRoutes) // Protected routes - require auth .derive(async ({ request, set }): Promise<{ user: User }> => { @@ -43,6 +51,7 @@ const app = new Elysia() .use(emailRoutes) .use(eventRoutes) .use(profileRoutes) + .use(adminRoutes) ) // Error handler @@ -63,6 +72,11 @@ const app = new Elysia() set.status = 401; return { error: 'Unauthorized' }; } + + if (error.message.includes('Forbidden')) { + set.status = 403; + return { error: error.message }; + } if (error.message.includes('not found')) { set.status = 404; @@ -77,4 +91,16 @@ const app = new Elysia() console.log(`🚀 Network App API running at ${app.server?.hostname}:${app.server?.port}`); +// Bootstrap: ensure donovan@donovankelly.xyz is admin +(async () => { + try { + await db.update(users) + .set({ role: 'admin' }) + .where(eq(users.email, 'donovan@donovankelly.xyz')); + console.log('✅ Admin bootstrap: donovan@donovankelly.xyz set as admin'); + } catch (e) { + console.error('Admin bootstrap failed (may be first run before tables exist):', e); + } +})(); + export type App = typeof app; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b33fa16..1324aad 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -17,6 +17,15 @@ export const auth = betterAuth({ plugins: [ bearer(), // Enable bearer token auth for mobile apps ], + user: { + additionalFields: { + role: { + type: 'string', + defaultValue: 'user', + input: false, // Don't allow setting via sign-up + }, + }, + }, emailAndPassword: { enabled: true, requireEmailVerification: false, // Enable later for production diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..bec23a7 --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,128 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { users, invites } from '../db/schema'; +import { eq, desc } from 'drizzle-orm'; +import { auth } from '../lib/auth'; +import type { User } from '../lib/auth'; + +export const adminRoutes = new Elysia({ prefix: '/admin' }) + // Admin guard — all routes in this group require admin role + .onBeforeHandle(({ user, set }: { user: User; set: any }) => { + if ((user as any).role !== 'admin') { + set.status = 403; + throw new Error('Forbidden: admin access required'); + } + }) + + // List all users + .get('/users', async () => { + const allUsers = await db.select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + createdAt: users.createdAt, + }) + .from(users) + .orderBy(desc(users.createdAt)); + + return allUsers; + }) + + // Update user role + .put('/users/:id/role', async ({ params, body, user, set }: { + params: { id: string }; + body: { role: string }; + user: User; + set: any; + }) => { + // Can't change own role + if (params.id === user.id) { + set.status = 400; + throw new Error('Cannot change your own role'); + } + + if (!['admin', 'user'].includes(body.role)) { + set.status = 400; + throw new Error('Invalid role'); + } + + const [updated] = await db.update(users) + .set({ role: body.role, updatedAt: new Date() }) + .where(eq(users.id, params.id)) + .returning({ id: users.id, role: users.role }); + + if (!updated) throw new Error('User not found'); + return updated; + }, { + params: t.Object({ id: t.String() }), + body: t.Object({ role: t.String() }), + }) + + // Delete user + .delete('/users/:id', async ({ params, user, set }: { + params: { id: string }; + user: User; + set: any; + }) => { + if (params.id === user.id) { + set.status = 400; + throw new Error('Cannot delete yourself'); + } + + const [deleted] = await db.delete(users) + .where(eq(users.id, params.id)) + .returning({ id: users.id }); + + if (!deleted) throw new Error('User not found'); + return { success: true, id: deleted.id }; + }, { + params: t.Object({ id: t.String() }), + }) + + // Create invite + .post('/invites', async ({ body, user }: { body: { email: string; name: string; role?: string }; user: User }) => { + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + const [invite] = await db.insert(invites).values({ + email: body.email, + name: body.name, + role: body.role || 'user', + token, + invitedBy: user.id, + status: 'pending', + expiresAt, + }).returning(); + + const appUrl = process.env.APP_URL || 'https://thenetwork.donovankelly.xyz'; + const setupUrl = `${appUrl}/invite/${token}`; + + return { ...invite, setupUrl }; + }, { + body: t.Object({ + email: t.String({ format: 'email' }), + name: t.String({ minLength: 1 }), + role: t.Optional(t.String()), + }), + }) + + // List invites + .get('/invites', async () => { + const allInvites = await db.select() + .from(invites) + .orderBy(desc(invites.createdAt)); + return allInvites; + }) + + // Revoke invite + .delete('/invites/:id', async ({ params }: { params: { id: string } }) => { + const [deleted] = await db.delete(invites) + .where(eq(invites.id, params.id)) + .returning({ id: invites.id }); + + if (!deleted) throw new Error('Invite not found'); + return { success: true, id: deleted.id }; + }, { + params: t.Object({ id: t.String() }), + }); diff --git a/src/routes/invite.ts b/src/routes/invite.ts new file mode 100644 index 0000000..3e31f74 --- /dev/null +++ b/src/routes/invite.ts @@ -0,0 +1,106 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { invites, users } from '../db/schema'; +import { eq, and } from 'drizzle-orm'; +import { auth } from '../lib/auth'; + +export const inviteRoutes = new Elysia({ prefix: '/auth/invite' }) + // Validate invite token (public - no auth required) + .get('/:token', async ({ params, set }: { params: { token: string }; set: any }) => { + const [invite] = await db.select() + .from(invites) + .where(and(eq(invites.token, params.token), eq(invites.status, 'pending'))) + .limit(1); + + if (!invite) { + set.status = 404; + throw new Error('Invite not found or already used'); + } + + if (new Date() > invite.expiresAt) { + // Mark as expired + await db.update(invites) + .set({ status: 'expired' }) + .where(eq(invites.id, invite.id)); + set.status = 410; + throw new Error('Invite has expired'); + } + + return { + id: invite.id, + email: invite.email, + name: invite.name, + role: invite.role, + expiresAt: invite.expiresAt, + }; + }, { + params: t.Object({ token: t.String() }), + }) + + // Accept invite (public - no auth required) + .post('/:token/accept', async ({ params, body, set }: { + params: { token: string }; + body: { password: string; name?: string }; + set: any; + }) => { + const [invite] = await db.select() + .from(invites) + .where(and(eq(invites.token, params.token), eq(invites.status, 'pending'))) + .limit(1); + + if (!invite) { + set.status = 404; + throw new Error('Invite not found or already used'); + } + + if (new Date() > invite.expiresAt) { + await db.update(invites) + .set({ status: 'expired' }) + .where(eq(invites.id, invite.id)); + set.status = 410; + throw new Error('Invite has expired'); + } + + // Create user via Better Auth's sign-up + try { + const signUpResult = await auth.api.signUpEmail({ + body: { + email: invite.email, + password: body.password, + name: body.name || invite.name, + }, + }); + + // Set the user's role from the invite + if (signUpResult?.user?.id) { + await db.update(users) + .set({ role: invite.role }) + .where(eq(users.id, signUpResult.user.id)); + } + + // Mark invite as accepted + await db.update(invites) + .set({ status: 'accepted' }) + .where(eq(invites.id, invite.id)); + + return { + success: true, + user: { + id: signUpResult.user.id, + email: signUpResult.user.email, + name: signUpResult.user.name, + role: invite.role, + }, + token: signUpResult.token, + }; + } catch (error: any) { + set.status = 400; + throw new Error(error.message || 'Failed to create account'); + } + }, { + params: t.Object({ token: t.String() }), + body: t.Object({ + password: t.String({ minLength: 8 }), + name: t.Optional(t.String()), + }), + });