Files
pn-new-crm/src/components/settings/user-settings.tsx
Matt 05b57abf05 refactor(settings): consolidate user profile into single settings page
Drop the standalone /settings/profile route + user-profile component;
folding the same fields into user-settings means one place to update
and one menu item. UserMenu loses the Profile dropdown entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:35:07 +02:00

595 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useMemo, useRef } from 'react';
import { Save, KeyRound, Globe, Upload } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
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 { WarningCallout } from '@/components/ui/warning-callout';
import { PageHeader } from '@/components/shared/page-header';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card';
import { apiFetch } from '@/lib/api/client';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import type { CountryCode } from '@/lib/i18n/countries';
interface MeResponse {
user?: { name: string; email: string };
preferences?: { country?: string; timezone?: string };
profile?: {
avatarFileId?: string | null;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
username?: string | null;
};
}
export function UserSettings() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [displayName, setDisplayName] = useState('');
const [username, setUsername] = useState('');
const [originalUsername, setOriginalUsername] = useState('');
const [usernameMsg, setUsernameMsg] = useState<string | null>(null);
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [originalEmail, setOriginalEmail] = useState('');
const [country, setCountry] = useState<string | null>(null);
const [timezone, setTimezone] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [resetMsg, setResetMsg] = useState<string | null>(null);
const [emailMsg, setEmailMsg] = useState<string | null>(null);
const [avatarFileId, setAvatarFileId] = useState<string | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [pendingAvatarFile, setPendingAvatarFile] = useState<File | null>(null);
const [cropperOpen, setCropperOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Browser-detected timezone — surfaces a banner when the rep is
// travelling and the saved value no longer matches their device's
// locale. Quietly absent in SSR; the picker still works without it.
const detectedTz = useMemo(() => {
if (typeof Intl === 'undefined') return null;
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return null;
}
}, []);
useEffect(() => {
async function loadProfile() {
const res = await apiFetch<{ data: MeResponse }>('/api/v1/me', { method: 'GET' });
setFirstName(res.data.profile?.firstName ?? '');
setLastName(res.data.profile?.lastName ?? '');
// Display name is the override; fall back to user.name if profile
// doesn't carry one (e.g. legacy rows pre-Wave 10).
setDisplayName(res.data.profile?.displayName ?? res.data.user?.name ?? '');
setEmail(res.data.user?.email ?? '');
setOriginalEmail(res.data.user?.email ?? '');
setUsername(res.data.profile?.username ?? '');
setOriginalUsername(res.data.profile?.username ?? '');
setCountry(res.data.preferences?.country ?? null);
// Fall back to the browser-detected zone when no value has been
// saved yet — first-time users land on a sensible default rather
// than an empty picker. Doesn't overwrite an explicit choice.
setTimezone(res.data.preferences?.timezone ?? detectedTz ?? null);
const fid = res.data.profile?.avatarFileId ?? null;
setAvatarFileId(fid);
setAvatarUrl(fid ? `/api/v1/files/${fid}/preview` : null);
}
void loadProfile();
}, [detectedTz]);
// When the user picks a country and no timezone is set, suggest the
// primary zone for that country. setState in effect is intentional —
// we're reacting to a discrete user choice (country picker).
useEffect(() => {
if (!country || timezone) return;
const primary = primaryTimezoneFor(country as CountryCode);
// eslint-disable-next-line react-hooks/set-state-in-effect
if (primary) setTimezone(primary);
}, [country, timezone]);
function handleAvatarPicked(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;
if (!file) return;
setPendingAvatarFile(file);
setCropperOpen(true);
// Reset so picking the same file twice still triggers onChange.
if (fileInputRef.current) fileInputRef.current.value = '';
}
async function uploadAvatar(blob: Blob) {
const fd = new FormData();
fd.append('file', new File([blob], 'avatar.jpg', { type: 'image/jpeg' }));
const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: fd });
if (!res.ok) throw new Error('Avatar upload failed');
const json = (await res.json()) as { data: { avatarFileId: string } };
setAvatarFileId(json.data.avatarFileId);
// Cache-bust the preview URL with a query so the new image renders.
setAvatarUrl(`/api/v1/files/${json.data.avatarFileId}/preview?t=${Date.now()}`);
}
function handleCountryChange(iso: string | null) {
setCountry(iso);
// Auto-default timezone when the rep picks a country and hasn't
// explicitly set a timezone yet. Same trick as the client overview.
if (iso && !timezone) {
const tz = primaryTimezoneFor(iso as CountryCode);
if (tz) setTimezone(tz);
}
}
async function saveProfile() {
setSaving('profile');
setMessage(null);
try {
await apiFetch('/api/v1/me', {
method: 'PATCH',
body: {
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
displayName: displayName || undefined,
phone: phone || null,
preferences: {
country: country ?? undefined,
timezone: timezone ?? undefined,
},
},
});
setMessage('Profile saved');
} catch (err: unknown) {
setMessage(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(null);
}
}
async function saveUsername() {
if (username.trim().toLowerCase() === originalUsername.toLowerCase()) return;
setSaving('username');
setUsernameMsg(null);
try {
const next = username.trim().toLowerCase() || null;
await apiFetch('/api/v1/me', { method: 'PATCH', body: { username: next } });
setOriginalUsername(next ?? '');
setUsername(next ?? '');
setUsernameMsg(
next
? `Username updated. You can now sign in with @${next} or your email.`
: 'Username cleared.',
);
} catch (err: unknown) {
setUsernameMsg(err instanceof Error ? err.message : 'Failed to save username');
} finally {
setSaving(null);
}
}
async function saveEmail() {
if (email === originalEmail) return;
setSaving('email');
setEmailMsg(null);
try {
await apiFetch('/api/v1/me/email', { method: 'PATCH', body: { email } });
setOriginalEmail(email);
setEmailMsg('Email updated. Use the new address next time you sign in.');
} catch (err: unknown) {
setEmailMsg(err instanceof Error ? err.message : 'Failed to update email');
} finally {
setSaving(null);
}
}
async function requestPasswordReset() {
setSaving('password');
setResetMsg(null);
try {
await apiFetch('/api/v1/me/password-reset', { method: 'POST' });
setResetMsg('Password-reset email sent. Check your inbox.');
} catch (err: unknown) {
setResetMsg(err instanceof Error ? err.message : 'Failed to send reset email');
} finally {
setSaving(null);
}
}
function adoptDetectedTz() {
if (detectedTz) setTimezone(detectedTz);
}
const tzMismatch = detectedTz && timezone && detectedTz !== timezone;
return (
<div>
<PageHeader title="Settings" description="Manage your profile and notification preferences" />
<div className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Update your display name and contact info</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Avatar — click the photo to pick a file, which opens the
cropper. Cropped result uploads via /api/v1/me/avatar. */}
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
{avatarUrl ? <AvatarImage src={avatarUrl} alt="Profile photo" /> : null}
<AvatarFallback>
{(displayName || email || 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<Label className="text-sm">Profile photo</Label>
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
{avatarFileId ? 'Replace photo' : 'Upload photo'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarPicked}
className="hidden"
/>
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG, or WebP up to 2 MB. You&apos;ll be able to crop after picking.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings-first-name">First name</Label>
<Input
id="settings-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Given name"
autoComplete="given-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings-last-name">Last name</Label>
<Input
id="settings-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Family name"
autoComplete="family-name"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="settings-name">
Display name <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="settings-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="How your name appears in the UI"
/>
<p className="text-xs text-muted-foreground">
Defaults to first + last when blank. Override with a nickname if you prefer.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="settings-phone">Phone</Label>
<PhoneInput
id="settings-phone"
value={
phone
? ({ e164: phone, country: (country as never) ?? 'US' } as PhoneInputValue)
: null
}
onChange={(next) => setPhone(next.e164 ?? '')}
placeholder="555 0123"
/>
</div>
<div className="space-y-2">
<Label>Country</Label>
<CountryCombobox value={country} onChange={handleCountryChange} />
<p className="text-xs text-muted-foreground">
Sets the default timezone when you pick a country and haven&apos;t chosen one
explicitly.
</p>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<TimezoneCombobox
value={timezone}
onChange={setTimezone}
countryHint={(country as CountryCode | null) ?? undefined}
/>
{tzMismatch && (
<WarningCallout icon={false}>
<span className="flex items-start gap-2 text-xs">
<Globe aria-hidden className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span className="flex-1">
Looks like you&apos;re in <strong>{detectedTz}</strong> right now (saved:{' '}
{timezone}).
<button
type="button"
onClick={adoptDetectedTz}
className="ml-1 underline underline-offset-2 hover:no-underline"
>
Update?
</button>
</span>
</span>
</WarningCallout>
)}
</div>
<div className="flex items-center gap-3">
<Button onClick={saveProfile} disabled={saving === 'profile'}>
<Save className="mr-1.5 h-4 w-4" />
{saving === 'profile' ? 'Saving…' : 'Save profile'}
</Button>
{message && <span className="text-sm text-muted-foreground">{message}</span>}
</div>
</CardContent>
</Card>
<DashboardWidgetsCard />
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Sign-in email and password</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="settings-username">
Username <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="settings-username"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
placeholder="yourname"
autoCapitalize="none"
spellCheck={false}
pattern="^[a-z0-9._-]{2,30}$"
/>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="outline"
onClick={saveUsername}
disabled={
saving === 'username' ||
username.trim().toLowerCase() === originalUsername.toLowerCase()
}
>
{saving === 'username' ? 'Saving…' : 'Save username'}
</Button>
{usernameMsg && (
<span className="text-xs text-muted-foreground">{usernameMsg}</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Optional alias you can use to sign in instead of your email. 230 lowercase letters,
digits, dot, underscore, or hyphen.
</p>
</div>
<div className="space-y-2 pt-2 border-t">
<Label htmlFor="settings-email">Email</Label>
<Input
id="settings-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="outline"
onClick={saveEmail}
disabled={saving === 'email' || email === originalEmail || !email}
>
{saving === 'email' ? 'Updating…' : 'Update email'}
</Button>
{emailMsg && <span className="text-xs text-muted-foreground">{emailMsg}</span>}
</div>
<p className="text-xs text-muted-foreground">
Changing your email also changes the address you use to sign in.
</p>
</div>
<div className="space-y-2 pt-2 border-t">
<Label>Password</Label>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="outline"
onClick={requestPasswordReset}
disabled={saving === 'password'}
>
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
{saving === 'password' ? 'Sending…' : 'Send reset email'}
</Button>
{resetMsg && <span className="text-xs text-muted-foreground">{resetMsg}</span>}
</div>
<p className="text-xs text-muted-foreground">
We&apos;ll email you a link to set a new password.
</p>
</div>
</CardContent>
</Card>
<ChangePasswordCard />
<section id="notifications" className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Notifications</h2>
<p className="text-sm text-muted-foreground">
Choose which notifications you receive and how they&apos;re delivered.
</p>
</div>
<NotificationPreferencesForm />
<ReminderDigestForm />
</section>
</div>
<ImageCropperDialog
open={cropperOpen}
onOpenChange={setCropperOpen}
file={pendingAvatarFile}
aspect={1}
outputWidth={256}
title="Crop profile photo"
onUpload={uploadAvatar}
/>
</div>
);
}
/** Direct change-password form (current + new). Lifted from the deprecated
* user-profile.tsx into the unified settings page so we keep both reset-via-
* email and change-in-place flows on a single screen. */
function ChangePasswordCard() {
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);
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 (
<Card>
<CardHeader>
<CardTitle>Change password</CardTitle>
<CardDescription>
Minimum 9 characters. You&rsquo;ll be prompted to sign in again on your other devices if
you check the box below.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={changePassword} className="space-y-4 max-w-md">
<div>
<Label htmlFor="currentPassword">Current password</Label>
<Input
id="currentPassword"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="newPassword">New password</Label>
<Input
id="newPassword"
type="password"
autoComplete="new-password"
required
minLength={9}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="confirmPassword">Confirm new password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={9}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1"
/>
</div>
<div className="flex items-center gap-2">
<input
id="revokeOthers"
type="checkbox"
checked={revokeOthers}
onChange={(e) => setRevokeOthers(e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="revokeOthers" className="text-sm font-normal cursor-pointer">
Sign out of other devices
</Label>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={savingPassword} size="sm">
Change password
</Button>
{passwordMessage ? (
<span
className={
passwordMessage.kind === 'ok' ? 'text-sm text-green-600' : 'text-sm text-red-600'
}
>
{passwordMessage.text}
</span>
) : null}
</div>
</form>
</CardContent>
</Card>
);
}