feat: password reset flow (public + admin-initiated)
This commit is contained in:
@@ -7,6 +7,7 @@ import { eventRoutes } from './routes/events';
|
|||||||
import { profileRoutes } from './routes/profile';
|
import { profileRoutes } from './routes/profile';
|
||||||
import { adminRoutes } from './routes/admin';
|
import { adminRoutes } from './routes/admin';
|
||||||
import { inviteRoutes } from './routes/invite';
|
import { inviteRoutes } from './routes/invite';
|
||||||
|
import { passwordResetRoutes } from './routes/password-reset';
|
||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
import { users } from './db/schema';
|
import { users } from './db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -34,8 +35,9 @@ const app = new Elysia()
|
|||||||
return auth.handler(request);
|
return auth.handler(request);
|
||||||
})
|
})
|
||||||
|
|
||||||
// Public invite routes (no auth required)
|
// Public routes (no auth required)
|
||||||
.use(inviteRoutes)
|
.use(inviteRoutes)
|
||||||
|
.use(passwordResetRoutes)
|
||||||
|
|
||||||
// Protected routes - require auth
|
// Protected routes - require auth
|
||||||
.derive(async ({ request, set }): Promise<{ user: User }> => {
|
.derive(async ({ request, set }): Promise<{ user: User }> => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users, invites } from '../db/schema';
|
import { users, invites, passwordResetTokens } from '../db/schema';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import { auth } from '../lib/auth';
|
import { auth } from '../lib/auth';
|
||||||
import type { User } from '../lib/auth';
|
import type { User } from '../lib/auth';
|
||||||
@@ -115,6 +115,39 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
|
|||||||
return allInvites;
|
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
|
// Revoke invite
|
||||||
.delete('/invites/:id', async ({ params }: { params: { id: string } }) => {
|
.delete('/invites/:id', async ({ params }: { params: { id: string } }) => {
|
||||||
const [deleted] = await db.delete(invites)
|
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