feat: password reset flow (public + admin-initiated)
This commit is contained in:
@@ -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)
|
||||
|
||||
148
src/routes/password-reset.ts
Normal file
148
src/routes/password-reset.ts
Normal 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 }),
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user