* chore: run Tailwind v4 upgrade tool (Phase 1) - Upgraded tailwindcss v3 → v4.2.1, postcss plugin to @tailwindcss/postcss - Deleted tailwind.config.js, migrated theme to CSS @theme block in index.css - Replaced @tailwind directives with @import 'tailwindcss' - Added @custom-variant dark, @utility blocks for custom utilities - Updated class names across 128 files (shadow-sm → shadow-xs, etc.) - Removed autoprefixer (built into v4) - Added migration plan doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: switch from @tailwindcss/postcss to @tailwindcss/vite (Phase 2) - Replaced @tailwindcss/postcss with @tailwindcss/vite plugin - Deleted postcss.config.js (no longer needed) - Tailwind now runs as a native Vite plugin for faster HMR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: convert to OKLCH colors, move keyframes into @theme (Phase 3-4) - Replaced all HSL color indirection with direct OKLCH values in @theme - Moved all keyframes inside @theme block (v4 pattern) - Eliminated hsl(var(--x)) double-indirection across 17 component files - Replaced hsl() inline styles with var(--color-*) theme references - Cleaned up redundant rdp-* utility blocks - Fixed @custom-variant dark syntax to use :where() - Added sidebar/glass/shadow vars as OKLCH in :root Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
185 lines
6.9 KiB
TypeScript
185 lines
6.9 KiB
TypeScript
import { useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react'
|
|
import { authApi } from '@/api/auth'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import type { UserUpdate } from '@/types'
|
|
|
|
const inputClass = cn(
|
|
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
)
|
|
|
|
export function ProfileSettingsPage() {
|
|
const user = useAuthStore((s) => s.user)
|
|
const fetchUser = useAuthStore((s) => s.fetchUser)
|
|
|
|
const [name, setName] = useState(user?.name ?? '')
|
|
const [email, setEmail] = useState(user?.email ?? '')
|
|
const [phone, setPhone] = useState(user?.phone ?? '')
|
|
const [jobTitle, setJobTitle] = useState(user?.job_title ?? '')
|
|
const [timezone, setTimezone] = useState(user?.timezone ?? 'UTC')
|
|
const [currentPassword, setCurrentPassword] = useState('')
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const emailChanged = email !== user?.email
|
|
const hasChanges =
|
|
emailChanged ||
|
|
name !== user?.name ||
|
|
phone !== (user?.phone ?? '') ||
|
|
jobTitle !== (user?.job_title ?? '') ||
|
|
timezone !== (user?.timezone ?? 'UTC')
|
|
|
|
const handleSave = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!hasChanges) return
|
|
|
|
setIsSaving(true)
|
|
setError(null)
|
|
try {
|
|
const payload: UserUpdate = {}
|
|
if (name !== user?.name) payload.name = name.trim()
|
|
if (emailChanged) {
|
|
payload.email = email.trim()
|
|
payload.current_password = currentPassword
|
|
}
|
|
if (phone !== (user?.phone ?? '')) payload.phone = phone.trim() || null
|
|
if (jobTitle !== (user?.job_title ?? '')) payload.job_title = jobTitle.trim() || null
|
|
if (timezone !== (user?.timezone ?? 'UTC')) payload.timezone = timezone
|
|
|
|
await authApi.updateProfile(payload)
|
|
await fetchUser()
|
|
setCurrentPassword('')
|
|
toast.success('Profile updated')
|
|
} catch (err) {
|
|
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
|
setError(axiosErr.response?.data?.detail ?? 'Failed to update profile')
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<UserIcon className="h-8 w-8 text-muted-foreground" />
|
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Profile Settings</h1>
|
|
</div>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Update your name, email, and personal details
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-xl">
|
|
<form onSubmit={handleSave} className="glass-card-static p-6 space-y-5">
|
|
{/* Name */}
|
|
<div>
|
|
<label htmlFor="profile-name" className="block text-sm font-medium text-foreground">Name</label>
|
|
<input id="profile-name" type="text" value={name} onChange={(e) => setName(e.target.value)} required className={inputClass} />
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div>
|
|
<label htmlFor="profile-email" className="block text-sm font-medium text-foreground">Email</label>
|
|
<input id="profile-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className={inputClass} />
|
|
</div>
|
|
|
|
{/* Password confirmation for email change */}
|
|
{emailChanged && (
|
|
<div>
|
|
<label htmlFor="profile-password" className="block text-sm font-medium text-foreground">Current Password</label>
|
|
<p className="text-xs text-muted-foreground">Required to change your email address</p>
|
|
<input id="profile-password" type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required className={inputClass} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Phone */}
|
|
<div>
|
|
<label htmlFor="profile-phone" className="block text-sm font-medium text-foreground">Phone</label>
|
|
<input id="profile-phone" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Optional" className={inputClass} />
|
|
</div>
|
|
|
|
{/* Job Title */}
|
|
<div>
|
|
<label htmlFor="profile-job-title" className="block text-sm font-medium text-foreground">Job Title</label>
|
|
<input id="profile-job-title" type="text" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} placeholder="e.g. Network Engineer" className={inputClass} />
|
|
</div>
|
|
|
|
{/* Timezone */}
|
|
<div>
|
|
<label htmlFor="profile-timezone" className="block text-sm font-medium text-foreground">Timezone</label>
|
|
<select id="profile-timezone" value={timezone} onChange={(e) => setTimezone(e.target.value)} className={inputClass}>
|
|
{COMMON_TIMEZONES.map((tz) => (
|
|
<option key={tz} value={tz}>{tz}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-2 text-sm text-rose-500">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={isSaving || !hasChanges}
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-brand-dark',
|
|
'shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
|
Save Changes
|
|
</button>
|
|
|
|
<Link
|
|
to="/change-password"
|
|
className={cn(
|
|
'inline-flex items-center rounded-[10px] px-4 py-2 text-sm font-medium',
|
|
'bg-[rgba(255,255,255,0.04)] border border-brand-border text-foreground',
|
|
'hover:border-[rgba(255,255,255,0.12)]'
|
|
)}
|
|
>
|
|
Change Password
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const COMMON_TIMEZONES = [
|
|
'UTC',
|
|
'America/New_York',
|
|
'America/Chicago',
|
|
'America/Denver',
|
|
'America/Los_Angeles',
|
|
'America/Anchorage',
|
|
'Pacific/Honolulu',
|
|
'America/Toronto',
|
|
'America/Vancouver',
|
|
'Europe/London',
|
|
'Europe/Paris',
|
|
'Europe/Berlin',
|
|
'Europe/Amsterdam',
|
|
'Asia/Tokyo',
|
|
'Asia/Shanghai',
|
|
'Asia/Kolkata',
|
|
'Asia/Dubai',
|
|
'Australia/Sydney',
|
|
'Australia/Melbourne',
|
|
'Pacific/Auckland',
|
|
]
|
|
|
|
export default ProfileSettingsPage
|