From d19b74b935dae2f02bb73e71acfebd092780b2ce Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 14:57:35 +0200 Subject: [PATCH] feat(profile): /settings/profile page + change-password endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../[portSlug]/settings/profile/page.tsx | 5 + src/app/api/v1/me/password/route.ts | 54 ++++ src/components/settings/user-profile.tsx | 230 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 src/app/(dashboard)/[portSlug]/settings/profile/page.tsx create mode 100644 src/app/api/v1/me/password/route.ts create mode 100644 src/components/settings/user-profile.tsx diff --git a/src/app/(dashboard)/[portSlug]/settings/profile/page.tsx b/src/app/(dashboard)/[portSlug]/settings/profile/page.tsx new file mode 100644 index 0000000..072f641 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/settings/profile/page.tsx @@ -0,0 +1,5 @@ +import { UserProfile } from '@/components/settings/user-profile'; + +export default function ProfilePage() { + return ; +} diff --git a/src/app/api/v1/me/password/route.ts b/src/app/api/v1/me/password/route.ts new file mode 100644 index 0000000..4924673 --- /dev/null +++ b/src/app/api/v1/me/password/route.ts @@ -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); + } +}); diff --git a/src/components/settings/user-profile.tsx b/src/components/settings/user-profile.tsx new file mode 100644 index 0000000..52c265f --- /dev/null +++ b/src/components/settings/user-profile.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Save } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { PageHeader } from '@/components/shared/page-header'; +import { apiFetch } from '@/lib/api/client'; + +interface MeUser { + id?: string; + email?: string; + name?: string; +} + +export function UserProfile() { + const [me, setMe] = useState(null); + const [displayName, setDisplayName] = useState(''); + const [savingProfile, setSavingProfile] = useState(false); + const [profileMessage, setProfileMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>( + null, + ); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [revokeOthers, setRevokeOthers] = useState(true); + const [savingPassword, setSavingPassword] = useState(false); + const [passwordMessage, setPasswordMessage] = useState<{ + kind: 'ok' | 'err'; + text: string; + } | null>(null); + + useEffect(() => { + void load(); + }, []); + + async function load() { + const res = await apiFetch<{ data: { user?: MeUser } }>('/api/v1/me'); + setMe(res.data.user ?? null); + setDisplayName(res.data.user?.name ?? ''); + } + + async function saveProfile() { + setSavingProfile(true); + setProfileMessage(null); + try { + await apiFetch('/api/v1/me', { + method: 'PATCH', + body: { displayName: displayName || undefined }, + }); + setProfileMessage({ kind: 'ok', text: 'Profile saved' }); + } catch (err) { + setProfileMessage({ + kind: 'err', + text: err instanceof Error ? err.message : 'Failed to save profile', + }); + } finally { + setSavingProfile(false); + } + } + + async function changePassword(e: React.FormEvent) { + e.preventDefault(); + setPasswordMessage(null); + if (newPassword.length < 9) { + setPasswordMessage({ kind: 'err', text: 'New password must be at least 9 characters' }); + return; + } + if (newPassword !== confirmPassword) { + setPasswordMessage({ kind: 'err', text: 'New password and confirmation do not match' }); + return; + } + setSavingPassword(true); + try { + await apiFetch('/api/v1/me/password', { + method: 'POST', + body: { currentPassword, newPassword, revokeOtherSessions: revokeOthers }, + }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordMessage({ + kind: 'ok', + text: revokeOthers + ? 'Password changed. Other sessions have been signed out.' + : 'Password changed.', + }); + } catch (err) { + setPasswordMessage({ + kind: 'err', + text: err instanceof Error ? err.message : 'Failed to change password', + }); + } finally { + setSavingPassword(false); + } + } + + return ( +
+ + +
+ + + Account + Identity and display preferences for your CRM account + + +
+ + +

+ Email is your sign-in identifier and cannot be changed here. +

+
+
+ + setDisplayName(e.target.value)} + placeholder="How your name appears in comments, audit log, and emails" + className="mt-1" + /> +
+
+ + {profileMessage ? ( + + {profileMessage.text} + + ) : null} +
+
+
+ + + + Change password + + Minimum 9 characters. You’ll be prompted to sign in again on your other devices + if you check the box below. + + + +
+
+ + setCurrentPassword(e.target.value)} + className="mt-1" + /> +
+
+ + setNewPassword(e.target.value)} + className="mt-1" + /> +
+
+ + setConfirmPassword(e.target.value)} + className="mt-1" + /> +
+
+ setRevokeOthers(e.target.checked)} + className="h-4 w-4" + /> + +
+
+ + {passwordMessage ? ( + + {passwordMessage.text} + + ) : null} +
+
+
+
+
+
+ ); +}