feat(profile): /settings/profile page + change-password endpoint

The user-menu's Profile link previously 404'd, and CRM users had no way
to change their password from inside the app.

- /api/v1/me/password POST wraps better-auth changePassword, surfaces a
  friendlier "Current password is incorrect" on the typical failure
  mode, and writes an audit_log row with metadata.revokedOtherSessions.
- /{port}/settings/profile renders display name + email + change-password
  card with current/new/confirm fields and a 'Sign out other devices'
  toggle.

End-to-end verified: wrong current pw → 400 with mapped message;
correct → 200 + audit row; revert → 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 14:57:35 +02:00
parent 1b78eadd36
commit d19b74b935
3 changed files with 289 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { UserProfile } from '@/components/settings/user-profile';
export default function ProfilePage() {
return <UserProfile />;
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, ValidationError } from '@/lib/errors';
const bodySchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(9, 'Password must be at least 9 characters'),
revokeOtherSessions: z.boolean().optional(),
});
export const POST = withAuth(async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const result = await auth.api.changePassword({
body: {
currentPassword: body.currentPassword,
newPassword: body.newPassword,
revokeOtherSessions: body.revokeOtherSessions,
},
headers: req.headers,
});
void createAuditLog({
portId: ctx.portId || null,
userId: ctx.userId,
action: 'password_change',
entityType: 'user',
entityId: ctx.userId,
metadata: {
revokedOtherSessions: !!body.revokeOtherSessions,
},
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { ok: true, user: result.user } });
} catch (err) {
if (err && typeof err === 'object' && 'message' in err) {
const msg = String((err as { message?: unknown }).message ?? '');
if (
msg.toLowerCase().includes('invalid password') ||
msg.toLowerCase().includes('incorrect')
) {
return errorResponse(new ValidationError('Current password is incorrect'));
}
}
return errorResponse(err);
}
});