feat: admin password reset generates self-service link instead of direct password set

This commit is contained in:
2026-01-28 19:13:36 +00:00
parent 410f6373d9
commit 6dec91d0d8
2 changed files with 87 additions and 21 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, 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

View File

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