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>
595 lines
23 KiB
TypeScript
595 lines
23 KiB
TypeScript
'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'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'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'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. 2–30 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'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'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’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>
|
||
);
|
||
}
|