feat: password reset flow (public + admin-initiated)

This commit is contained in:
2026-01-28 21:43:38 +00:00
parent df4e67b929
commit 6b075b9ab5
3 changed files with 185 additions and 2 deletions

View File

@@ -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)

View File

@@ -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<string> {
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 }),
}),
});