diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 45a76e6..b786ebc 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/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, verifications } from '../db/schema'; import { eq, desc } from 'drizzle-orm'; import { sendInviteEmail } from '../lib/email'; import { auth } from '../lib/auth'; @@ -83,8 +83,8 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) }), }) - // Reset user password - .post('/users/:id/reset-password', async ({ params, body, set }) => { + // Generate password reset link + .post('/users/:id/reset-password', async ({ params, set }) => { const targetUser = await db.query.users.findFirst({ where: eq(users.id, params.id), }); @@ -94,27 +94,24 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) throw new Error('User not found'); } - try { - await auth.api.setPassword({ - body: { - userId: params.id, - newPassword: body.newPassword, - }, - }); - } catch (error) { - console.error('Failed to set password via auth.api:', error); - set.status = 500; - throw new Error('Failed to reset password'); - } + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - return { success: true, message: 'Password reset successfully' }; + await db.insert(verifications).values({ + id: crypto.randomUUID(), + identifier: `password-reset:${params.id}`, + value: token, + expiresAt, + }); + + const appUrl = process.env.APP_URL || 'https://todo.donovankelly.xyz'; + const resetUrl = `${appUrl}/reset-password?token=${token}`; + + return { success: true, resetUrl }; }, { params: t.Object({ id: t.String(), }), - body: t.Object({ - newPassword: t.String({ minLength: 8 }), - }), }) // Delete user diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index a25a4d1..f4fda37 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,7 +1,7 @@ import { Elysia, t } from 'elysia'; import { db } from '../db'; -import { invites, users, projects } from '../db/schema'; -import { eq, and, gt } from 'drizzle-orm'; +import { invites, users, projects, verifications } from '../db/schema'; +import { eq, and, gt, like } from 'drizzle-orm'; import { auth } from '../lib/auth'; export const authRoutes = new Elysia({ prefix: '/auth' }) @@ -127,4 +127,73 @@ export const authRoutes = new Elysia({ prefix: '/auth' }) body: t.Object({ password: t.String({ minLength: 8 }), }), + }) + + // Validate password reset token (public) + .get('/reset-password/:token', async ({ params, set }) => { + const verification = await db.query.verifications.findFirst({ + where: and( + eq(verifications.value, params.token), + gt(verifications.expiresAt, new Date()) + ), + }); + + if (!verification || !verification.identifier.startsWith('password-reset:')) { + set.status = 404; + throw new Error('Invalid or expired reset link'); + } + + const userId = verification.identifier.replace('password-reset:', ''); + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { name: true }, + }); + + return { valid: true, userName: user?.name || 'User' }; + }, { + params: t.Object({ + token: t.String(), + }), + }) + + // Submit password reset (public) + .post('/reset-password/:token', async ({ params, body, set }) => { + const verification = await db.query.verifications.findFirst({ + where: and( + eq(verifications.value, params.token), + gt(verifications.expiresAt, new Date()) + ), + }); + + if (!verification || !verification.identifier.startsWith('password-reset:')) { + set.status = 404; + throw new Error('Invalid or expired reset link'); + } + + const userId = verification.identifier.replace('password-reset:', ''); + + try { + await auth.api.setPassword({ + body: { + userId, + newPassword: body.newPassword, + }, + }); + } catch (error) { + console.error('Failed to set password:', error); + set.status = 500; + throw new Error('Failed to reset password'); + } + + // Delete the used verification token + await db.delete(verifications).where(eq(verifications.id, verification.id)); + + return { success: true, message: 'Password reset successfully' }; + }, { + params: t.Object({ + token: t.String(), + }), + body: t.Object({ + newPassword: t.String({ minLength: 8 }), + }), });