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:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user