Add user profile page, password change, reset links, auto-seed RBAC

Backend:
- POST /v1/auth/change-password (current user)
- POST /v1/auth/reset-password/:userId (admin generates 24h signed link)
- POST /v1/auth/reset-password (token-based reset, no auth required)
- GET/PATCH /v1/auth/me (profile read/update)
- Auto-seed system permissions on server startup

Frontend:
- Profile page with name edit, password change, theme/color settings
- Sidebar user link goes to profile page (replaces dropdown)
- Users page: "Reset Password Link" in kebab (copies to clipboard)
- Sign out button below profile link
This commit is contained in:
Ryan Moon
2026-03-28 17:59:55 -05:00
parent 58bf54a251
commit 7dea20e818
7 changed files with 385 additions and 81 deletions

View File

@@ -142,4 +142,112 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
token,
})
})
// Change own password
app.post('/auth/change-password', { preHandler: [app.authenticate] }, async (request, reply) => {
const { currentPassword, newPassword } = request.body as { currentPassword?: string; newPassword?: string }
if (!currentPassword || !newPassword) {
return reply.status(400).send({ error: { message: 'currentPassword and newPassword are required', statusCode: 400 } })
}
if (newPassword.length < 12) {
return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } })
}
const [user] = await app.db.select().from(users).where(eq(users.id, request.user.id)).limit(1)
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
const valid = await bcrypt.compare(currentPassword, user.passwordHash)
if (!valid) {
return reply.status(401).send({ error: { message: 'Current password is incorrect', statusCode: 401 } })
}
const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS)
await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, request.user.id))
request.log.info({ userId: request.user.id }, 'Password changed')
return reply.send({ message: 'Password changed' })
})
// Admin: generate password reset token for a user
app.post('/auth/reset-password/:userId', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => {
const { userId } = request.params as { userId: string }
const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1)
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
// Generate a signed reset token that expires in 24 hours
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' }, { expiresIn: '24h' })
const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}`
request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated')
return reply.send({ resetLink, expiresIn: '24 hours' })
})
// Reset password with token
app.post('/auth/reset-password', async (request, reply) => {
const { token, newPassword } = request.body as { token?: string; newPassword?: string }
if (!token || !newPassword) {
return reply.status(400).send({ error: { message: 'token and newPassword are required', statusCode: 400 } })
}
if (newPassword.length < 12) {
return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } })
}
try {
const payload = app.jwt.verify<{ userId: string; purpose: string }>(token)
if (payload.purpose !== 'password-reset') {
return reply.status(400).send({ error: { message: 'Invalid reset token', statusCode: 400 } })
}
const newHash = await bcrypt.hash(newPassword, SALT_ROUNDS)
await app.db.update(users).set({ passwordHash: newHash, updatedAt: new Date() }).where(eq(users.id, payload.userId))
request.log.info({ userId: payload.userId }, 'Password reset via token')
return reply.send({ message: 'Password reset successfully' })
} catch {
return reply.status(400).send({ error: { message: 'Invalid or expired reset token', statusCode: 400 } })
}
})
// Get current user profile
app.get('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => {
const [user] = await app.db
.select({
id: users.id,
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, request.user.id))
.limit(1)
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
return reply.send(user)
})
// Update current user profile
app.patch('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => {
const { firstName, lastName } = request.body as { firstName?: string; lastName?: string }
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (firstName) updates.firstName = firstName
if (lastName) updates.lastName = lastName
const [user] = await app.db
.update(users)
.set(updates)
.where(eq(users.id, request.user.id))
.returning({
id: users.id,
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
role: users.role,
})
return reply.send(user)
})
}