diff --git a/src/index.ts b/src/index.ts index 5496e2e..0f5cde9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { eventRoutes } from './routes/events'; import { profileRoutes } from './routes/profile'; import { adminRoutes } from './routes/admin'; import { inviteRoutes } from './routes/invite'; +import { passwordResetRoutes } from './routes/password-reset'; import { db } from './db'; import { users } from './db/schema'; import { eq } from 'drizzle-orm'; @@ -34,8 +35,9 @@ const app = new Elysia() return auth.handler(request); }) - // Public invite routes (no auth required) + // Public routes (no auth required) .use(inviteRoutes) + .use(passwordResetRoutes) // Protected routes - require auth .derive(async ({ request, set }): Promise<{ user: User }> => { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index bec23a7..536e216 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,6 +1,6 @@ import { Elysia, t } from 'elysia'; import { db } from '../db'; -import { users, invites } from '../db/schema'; +import { users, invites, passwordResetTokens } from '../db/schema'; import { eq, desc } from 'drizzle-orm'; import { auth } from '../lib/auth'; import type { User } from '../lib/auth'; @@ -115,6 +115,39 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) return allInvites; }) + // Generate password reset link for a user (admin-initiated) + .post('/users/:id/reset-password', async ({ params, set }: { + params: { id: string }; + set: any; + }) => { + // Check user exists + const [targetUser] = await db.select({ id: users.id, email: users.email }) + .from(users) + .where(eq(users.id, params.id)) + .limit(1); + + if (!targetUser) { + set.status = 404; + throw new Error('User not found'); + } + + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours for admin-generated links + + await db.insert(passwordResetTokens).values({ + userId: targetUser.id, + token, + expiresAt, + }); + + const appUrl = process.env.APP_URL || 'https://thenetwork.donovankelly.xyz'; + const resetUrl = `${appUrl}/reset-password/${token}`; + + return { resetUrl, email: targetUser.email }; + }, { + params: t.Object({ id: t.String() }), + }) + // Revoke invite .delete('/invites/:id', async ({ params }: { params: { id: string } }) => { const [deleted] = await db.delete(invites) diff --git a/src/routes/password-reset.ts b/src/routes/password-reset.ts new file mode 100644 index 0000000..914e91c --- /dev/null +++ b/src/routes/password-reset.ts @@ -0,0 +1,148 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { users, accounts, passwordResetTokens } from '../db/schema'; +import { eq, and, isNull } from 'drizzle-orm'; + +// Helper: hash password the same way Better Auth does (bcrypt via Bun) +async function hashPassword(password: string): Promise { + return Bun.password.hash(password, { algorithm: 'bcrypt', cost: 10 }); +} + +export const passwordResetRoutes = new Elysia({ prefix: '/auth/reset-password' }) + + // Request password reset (public) — generates token, returns reset URL + .post('/request', async ({ body, set }: { + body: { email: string }; + set: any; + }) => { + // Find user by email + const [user] = await db.select({ id: users.id, email: users.email }) + .from(users) + .where(eq(users.email, body.email)) + .limit(1); + + if (!user) { + // Don't reveal whether the email exists — return success either way + return { success: true, message: 'If an account with that email exists, a reset link has been generated.' }; + } + + // Generate token + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await db.insert(passwordResetTokens).values({ + userId: user.id, + token, + expiresAt, + }); + + const appUrl = process.env.APP_URL || 'https://thenetwork.donovankelly.xyz'; + const resetUrl = `${appUrl}/reset-password/${token}`; + + // For now, return the URL in the response (no email sending yet) + return { + success: true, + message: 'If an account with that email exists, a reset link has been generated.', + resetUrl, // Remove this once email sending is set up + }; + }, { + body: t.Object({ + email: t.String({ format: 'email' }), + }), + }) + + // Validate reset token (public) + .get('/:token', async ({ params, set }: { + params: { token: string }; + set: any; + }) => { + const [resetToken] = await db.select({ + id: passwordResetTokens.id, + userId: passwordResetTokens.userId, + expiresAt: passwordResetTokens.expiresAt, + usedAt: passwordResetTokens.usedAt, + }) + .from(passwordResetTokens) + .where(and( + eq(passwordResetTokens.token, params.token), + isNull(passwordResetTokens.usedAt), + )) + .limit(1); + + if (!resetToken) { + set.status = 404; + throw new Error('Reset token not found or already used'); + } + + if (new Date() > resetToken.expiresAt) { + set.status = 410; + throw new Error('Reset token has expired'); + } + + // Get user email to display + const [user] = await db.select({ email: users.email }) + .from(users) + .where(eq(users.id, resetToken.userId)) + .limit(1); + + return { + valid: true, + email: user?.email, + }; + }, { + params: t.Object({ token: t.String() }), + }) + + // Reset password with token (public) + .post('/:token', async ({ params, body, set }: { + params: { token: string }; + body: { password: string }; + set: any; + }) => { + const [resetToken] = await db.select() + .from(passwordResetTokens) + .where(and( + eq(passwordResetTokens.token, params.token), + isNull(passwordResetTokens.usedAt), + )) + .limit(1); + + if (!resetToken) { + set.status = 404; + throw new Error('Reset token not found or already used'); + } + + if (new Date() > resetToken.expiresAt) { + set.status = 410; + throw new Error('Reset token has expired'); + } + + // Hash the new password + const hashedPassword = await hashPassword(body.password); + + // Update the password in the accounts table (Better Auth stores passwords there) + const [updated] = await db.update(accounts) + .set({ password: hashedPassword, updatedAt: new Date() }) + .where(and( + eq(accounts.userId, resetToken.userId), + eq(accounts.providerId, 'credential'), + )) + .returning({ id: accounts.id }); + + if (!updated) { + set.status = 400; + throw new Error('Failed to update password — no credential account found'); + } + + // Mark token as used + await db.update(passwordResetTokens) + .set({ usedAt: new Date() }) + .where(eq(passwordResetTokens.id, resetToken.id)); + + return { success: true, message: 'Password has been reset successfully' }; + }, { + params: t.Object({ token: t.String() }), + body: t.Object({ + password: t.String({ minLength: 8 }), + }), + });