Add medication Items catalogue, counter POS/QR purchase, profile/login, detail pages, loyalty ledger, Cmd+K nav, KPI strips/charts, avatars, and i18n; document the new APIs and open questions for the shared backend team. Co-authored-by: Cursor <cursoragent@cursor.com>
746 lines
30 KiB
TypeScript
746 lines
30 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { Link, Navigate } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Bell,
|
|
Building2,
|
|
Check,
|
|
KeyRound,
|
|
Languages,
|
|
LogOut,
|
|
Monitor,
|
|
Pencil,
|
|
ShieldCheck,
|
|
SlidersHorizontal,
|
|
Sparkles,
|
|
UserRound,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { DetailSection } from '@/components/detail/ActivityTimeline'
|
|
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { SelectNative } from '@/components/ui/select-native'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { PersonAvatar } from '@/components/ui/avatar'
|
|
import { StatusBadge } from '@/components/ui/status-badge'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { IconTile } from '@/components/ui/icon-tile'
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { branchProfiles, branches, staffActivity, staffProfiles, staffSessions } from '@/mocks/data'
|
|
import type { StaffProfile, StaffSession } from '@/mocks/data'
|
|
import { canAccess, demoAccounts, roleModules } from '@/config/permissions'
|
|
import { sidebarEntries } from '@/config/navigation'
|
|
import { useAuthStore } from '@/zustand/authStore'
|
|
import { formatDate, formatDateTime, formatRelative, humanize } from '@/lib/utils'
|
|
import i18n from '@/i18n/config'
|
|
|
|
const LOCALE_LABEL: Record<'en' | 'am', string> = { en: 'English', am: 'አማርኛ' }
|
|
|
|
const NOTIFICATION_FIELDS: { key: keyof StaffProfile['notifications']; label: string; description: string }[] = [
|
|
{ key: 'orderAlerts', label: 'Order alerts', description: 'New and at-risk orders on your branch' },
|
|
{ key: 'lowStockAlerts', label: 'Low stock alerts', description: 'Reorder points and near-expiry batches' },
|
|
{ key: 'weeklyDigest', label: 'Weekly digest', description: 'A Monday summary of your modules' },
|
|
{ key: 'securityAlerts', label: 'Security alerts', description: 'New sign-ins and permission changes' },
|
|
]
|
|
|
|
function fallbackProfile(id: string): StaffProfile {
|
|
return {
|
|
id,
|
|
jobTitle: 'Staff member',
|
|
phone: '',
|
|
status: 'active',
|
|
joinedAt: new Date().toISOString(),
|
|
lastSignInAt: new Date().toISOString(),
|
|
lastSignInFrom: 'Addis Ababa, Ethiopia',
|
|
passwordUpdatedAt: new Date().toISOString(),
|
|
twoFactorEnabled: false,
|
|
preferredLocale: 'en',
|
|
notifications: { orderAlerts: false, lowStockAlerts: false, weeklyDigest: false, securityAlerts: true },
|
|
}
|
|
}
|
|
|
|
function ToggleSwitch({
|
|
checked,
|
|
onChange,
|
|
label,
|
|
}: {
|
|
checked: boolean
|
|
onChange: (next: boolean) => void
|
|
label: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
aria-label={label}
|
|
onClick={() => onChange(!checked)}
|
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
|
checked ? 'bg-brand-500' : 'bg-grayScale-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
|
|
checked ? 'translate-x-5' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function ProfilePage() {
|
|
const { t } = useTranslation()
|
|
const user = useAuthStore((s) => s.user)
|
|
const updateUser = useAuthStore((s) => s.updateUser)
|
|
|
|
// Resolved once: editing your own email must not detach the page from its mocks.
|
|
const [accountId] = useState(
|
|
() => demoAccounts.find((a) => a.email === user?.email)?.id ?? user?.id ?? 'unknown',
|
|
)
|
|
|
|
const [profile, setProfile] = useState<StaffProfile>(
|
|
() => staffProfiles[accountId] ?? fallbackProfile(accountId),
|
|
)
|
|
const [sessions, setSessions] = useState<StaffSession[]>(() => staffSessions[accountId] ?? [])
|
|
const [editing, setEditing] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [form, setForm] = useState({
|
|
name: user?.name ?? '',
|
|
email: user?.email ?? '',
|
|
phone: profile.phone,
|
|
jobTitle: profile.jobTitle,
|
|
locale: (user?.preferredLocale ?? profile.preferredLocale) as 'en' | 'am',
|
|
})
|
|
const [passwordOpen, setPasswordOpen] = useState(false)
|
|
const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' })
|
|
|
|
const role = user?.role
|
|
const modules = useMemo(() => {
|
|
if (!role) return []
|
|
return roleModules[role].map((module) => {
|
|
const entry = sidebarEntries.find((e) => e.module === module)
|
|
return {
|
|
module,
|
|
label: entry?.labelKey
|
|
? t(entry.labelKey, { defaultValue: entry.title })
|
|
: (entry?.title ?? humanize(module)),
|
|
path: entry?.path,
|
|
icon: entry?.icon,
|
|
}
|
|
})
|
|
}, [role, t])
|
|
|
|
const activity = useMemo(
|
|
() =>
|
|
(staffActivity[accountId] ?? [])
|
|
.slice()
|
|
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()),
|
|
[accountId],
|
|
)
|
|
|
|
const branch = useMemo(() => branches.find((b) => b.id === user?.branchId), [user?.branchId])
|
|
const branchProfile = user?.branchId ? branchProfiles[user.branchId] : undefined
|
|
|
|
if (!user || !role) return <Navigate to="/login" replace />
|
|
|
|
const totalModules = new Set(Object.values(roleModules).flat()).size
|
|
|
|
const persistProfile = (next: StaffProfile) => {
|
|
staffProfiles[next.id] = next
|
|
setProfile(next)
|
|
}
|
|
|
|
const startEditing = () => {
|
|
setForm({
|
|
name: user.name,
|
|
email: user.email,
|
|
phone: profile.phone,
|
|
jobTitle: profile.jobTitle,
|
|
locale: (user.preferredLocale ?? profile.preferredLocale) as 'en' | 'am',
|
|
})
|
|
setEditing(true)
|
|
}
|
|
|
|
const saveAccountDetails = () => {
|
|
if (!form.name.trim()) {
|
|
toast.error('Enter your full name')
|
|
return
|
|
}
|
|
if (!form.email.includes('@')) {
|
|
toast.error('Enter a valid email address')
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
window.setTimeout(() => {
|
|
persistProfile({
|
|
...profile,
|
|
phone: form.phone.trim(),
|
|
jobTitle: form.jobTitle.trim(),
|
|
preferredLocale: form.locale,
|
|
})
|
|
updateUser({ name: form.name.trim(), email: form.email.trim(), preferredLocale: form.locale })
|
|
if (i18n.language !== form.locale) void i18n.changeLanguage(form.locale)
|
|
setSaving(false)
|
|
setEditing(false)
|
|
toast.success('Profile updated')
|
|
}, 400)
|
|
}
|
|
|
|
const changeLocale = (locale: 'en' | 'am') => {
|
|
if (locale === (user.preferredLocale ?? profile.preferredLocale)) return
|
|
persistProfile({ ...profile, preferredLocale: locale })
|
|
updateUser({ preferredLocale: locale })
|
|
setForm((prev) => ({ ...prev, locale }))
|
|
void i18n.changeLanguage(locale)
|
|
toast.success(`Language set to ${LOCALE_LABEL[locale]}`)
|
|
}
|
|
|
|
const toggleNotification = (key: keyof StaffProfile['notifications'], next: boolean) => {
|
|
persistProfile({ ...profile, notifications: { ...profile.notifications, [key]: next } })
|
|
toast.success(`${NOTIFICATION_FIELDS.find((f) => f.key === key)?.label} ${next ? 'on' : 'off'}`)
|
|
}
|
|
|
|
const toggleTwoFactor = () => {
|
|
const next = !profile.twoFactorEnabled
|
|
persistProfile({ ...profile, twoFactorEnabled: next })
|
|
toast.success(next ? 'Two-factor authentication enabled' : 'Two-factor authentication disabled')
|
|
}
|
|
|
|
const revokeSession = (session: StaffSession) => {
|
|
setSessions((prev) => prev.filter((s) => s.id !== session.id))
|
|
staffSessions[accountId] = (staffSessions[accountId] ?? []).filter((s) => s.id !== session.id)
|
|
toast.success(`Signed out ${session.device}`)
|
|
}
|
|
|
|
const revokeOtherSessions = () => {
|
|
const remaining = sessions.filter((s) => s.current)
|
|
setSessions(remaining)
|
|
staffSessions[accountId] = remaining
|
|
toast.success('All other sessions signed out')
|
|
}
|
|
|
|
const submitPasswordChange = () => {
|
|
if (!passwordForm.current) {
|
|
toast.error('Enter your current password')
|
|
return
|
|
}
|
|
if (passwordForm.next.length < 8) {
|
|
toast.error('New password must be at least 8 characters')
|
|
return
|
|
}
|
|
if (passwordForm.next !== passwordForm.confirm) {
|
|
toast.error('New passwords do not match')
|
|
return
|
|
}
|
|
persistProfile({ ...profile, passwordUpdatedAt: new Date().toISOString() })
|
|
setPasswordForm({ current: '', next: '', confirm: '' })
|
|
setPasswordOpen(false)
|
|
toast.success('Password updated')
|
|
}
|
|
|
|
const activeLocale = (user.preferredLocale ?? profile.preferredLocale) as 'en' | 'am'
|
|
|
|
return (
|
|
<div className="w-full min-w-0 space-y-5">
|
|
<section className="overflow-hidden rounded-2xl border bg-card shadow-soft">
|
|
<div className="relative h-24 bg-gradient-to-r from-brand-500 via-brand-400 to-orange">
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
backgroundImage: 'radial-gradient(rgba(20,20,20,0.12) 1px, transparent 1px)',
|
|
backgroundSize: '18px 18px',
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-4 px-5 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
|
<div className="-mt-12 flex flex-col gap-4 sm:flex-row sm:items-end">
|
|
<PersonAvatar
|
|
name={user.name}
|
|
avatarUrl={user.avatarUrl}
|
|
size="xl"
|
|
className="h-24 w-24 text-2xl ring-4 ring-card"
|
|
/>
|
|
<div className="min-w-0 pb-1">
|
|
<h1 className="font-display text-2xl font-bold tracking-tight text-forest">{user.name}</h1>
|
|
<p className="mt-0.5 truncate text-sm text-muted-foreground">{user.email}</p>
|
|
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
<StatusBadge domain="role" status={role} />
|
|
<Badge variant="outline" className="gap-1.5">
|
|
<Building2 className="h-3 w-3" aria-hidden="true" />
|
|
{user.branchName ?? 'All branches'}
|
|
</Badge>
|
|
<Badge variant={profile.status === 'active' ? 'success' : 'warning'} className="gap-1.5">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
|
|
{humanize(profile.status)}
|
|
</Badge>
|
|
<Badge variant="muted">{profile.jobTitle}</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{editing ? (
|
|
<>
|
|
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => setEditing(false)}>
|
|
<X className="h-3.5 w-3.5" />
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" className="h-9 gap-1.5" disabled={saving} onClick={saveAccountDetails}>
|
|
<Check className="h-3.5 w-3.5" />
|
|
{saving ? 'Saving…' : 'Save changes'}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => setPasswordOpen(true)}>
|
|
<KeyRound className="h-3.5 w-3.5" />
|
|
Change password
|
|
</Button>
|
|
<Button size="sm" className="h-9 gap-1.5" onClick={startEditing}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit profile
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="grid gap-5 [&>*]:min-w-0 lg:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
|
|
<div className="space-y-5">
|
|
<DetailSection
|
|
title="Account details"
|
|
description="How you appear across the console"
|
|
action={
|
|
editing ? null : (
|
|
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-muted-foreground" onClick={startEditing}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</Button>
|
|
)
|
|
}
|
|
>
|
|
{editing ? (
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="profile-name">Full name</Label>
|
|
<Input
|
|
id="profile-name"
|
|
value={form.name}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="profile-email">Email</Label>
|
|
<Input
|
|
id="profile-email"
|
|
type="email"
|
|
value={form.email}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="profile-phone">Phone</Label>
|
|
<Input
|
|
id="profile-phone"
|
|
value={form.phone}
|
|
placeholder="+251 …"
|
|
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="profile-job">Job title</Label>
|
|
<Input
|
|
id="profile-job"
|
|
value={form.jobTitle}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, jobTitle: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="profile-locale">Language preference</Label>
|
|
<SelectNative
|
|
id="profile-locale"
|
|
value={form.locale}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, locale: e.target.value as 'en' | 'am' }))}
|
|
>
|
|
<option value="en">{LOCALE_LABEL.en}</option>
|
|
<option value="am">{LOCALE_LABEL.am}</option>
|
|
</SelectNative>
|
|
</div>
|
|
</div>
|
|
<Separator />
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setEditing(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button disabled={saving} onClick={saveAccountDetails}>
|
|
{saving ? 'Saving…' : 'Save changes'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<KeyValueGrid
|
|
columns={3}
|
|
items={[
|
|
{ label: 'Full name', value: user.name },
|
|
{ label: 'Email', value: user.email },
|
|
{ label: 'Phone', value: profile.phone || '—' },
|
|
{ label: 'Job title', value: profile.jobTitle },
|
|
{ label: 'Language', value: LOCALE_LABEL[activeLocale] },
|
|
{ label: 'Member since', value: formatDate(profile.joinedAt) },
|
|
]}
|
|
/>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="Role & permissions"
|
|
description={`${modules.length} of ${totalModules} modules enabled for this role`}
|
|
action={<StatusBadge domain="role" status={role} />}
|
|
>
|
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
{modules.map(({ module, label, path, icon }) => {
|
|
const content = (
|
|
<>
|
|
{icon ? (
|
|
<IconTile icon={icon} size="sm" tone="brand" />
|
|
) : (
|
|
<IconTile icon={Sparkles} size="sm" tone="muted" />
|
|
)}
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm font-medium text-foreground">{label}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">{module}</span>
|
|
</span>
|
|
</>
|
|
)
|
|
return path ? (
|
|
<Link
|
|
key={module}
|
|
to={path}
|
|
className="flex items-center gap-3 rounded-xl border bg-background p-3 transition-colors hover:bg-grayScale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div key={module} className="flex items-center gap-3 rounded-xl border bg-background p-3">
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
<p className="mt-4 text-xs text-muted-foreground">
|
|
Module access is granted by role. Ask a super admin if you need a module that is not listed here.
|
|
</p>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Branch assignment" description="Where your work is scoped">
|
|
{branch ? (
|
|
<div className="space-y-4">
|
|
<KeyValueGrid
|
|
columns={3}
|
|
items={[
|
|
{ label: 'Branch', value: branch.name },
|
|
{ label: 'Zone', value: branch.zone },
|
|
{ label: 'Phone', value: branch.phone },
|
|
{ label: 'Manager', value: branchProfile?.manager ?? '—' },
|
|
{ label: 'Opening hours', value: branchProfile?.openingHours ?? '—' },
|
|
{ label: 'Address', value: branchProfile?.address ?? '—' },
|
|
]}
|
|
/>
|
|
{canAccess(role, 'settings') ? (
|
|
<Button asChild variant="outline" size="sm" className="h-9">
|
|
<Link to={`/settings/branches/${branch.id}`}>Open branch settings</Link>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-start gap-3 rounded-xl border bg-background p-4">
|
|
<IconTile icon={Building2} size="sm" tone="forest" />
|
|
<div>
|
|
<p className="text-sm font-medium text-foreground">All branches</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
This account is platform-wide and is not scoped to a single branch.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Recent activity" description="Your last actions in the console">
|
|
{activity.length === 0 ? (
|
|
<EmptyState
|
|
icon={UserRound}
|
|
title="No activity yet"
|
|
description="Actions you take in the console will show up here."
|
|
variant="dashed"
|
|
/>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>When</TableHead>
|
|
<TableHead>Activity</TableHead>
|
|
<TableHead>Module</TableHead>
|
|
<TableHead>Outcome</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{activity.map((event) => {
|
|
const linkable = event.targetPath && canAccess(role, event.module)
|
|
return (
|
|
<TableRow key={event.id}>
|
|
<TableCell className="whitespace-nowrap align-top">
|
|
<span className="block text-sm font-medium text-foreground">
|
|
{formatRelative(event.at)}
|
|
</span>
|
|
<span className="block text-xs text-muted-foreground">{formatDateTime(event.at)}</span>
|
|
</TableCell>
|
|
<TableCell className="align-top">
|
|
<span className="block text-sm font-medium text-foreground">{event.summary}</span>
|
|
<span className="block font-mono text-xs text-muted-foreground">{event.action}</span>
|
|
{linkable ? (
|
|
<Link
|
|
to={event.targetPath as string}
|
|
className="mt-0.5 inline-block text-xs font-medium text-brand-700 underline-offset-2 hover:underline"
|
|
>
|
|
{event.target}
|
|
</Link>
|
|
) : event.target ? (
|
|
<span className="mt-0.5 block text-xs text-muted-foreground">{event.target}</span>
|
|
) : null}
|
|
</TableCell>
|
|
<TableCell className="align-top">
|
|
<Badge variant="outline">{humanize(event.module)}</Badge>
|
|
</TableCell>
|
|
<TableCell className="align-top">
|
|
<Badge variant={event.outcome === 'success' ? 'success' : 'danger'}>
|
|
{humanize(event.outcome)}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</DetailSection>
|
|
</div>
|
|
|
|
<div className="space-y-5">
|
|
<DetailSection title="Security" description="Password, verification and devices">
|
|
<div className="space-y-4">
|
|
<KeyValueGrid
|
|
columns={1}
|
|
items={[
|
|
{
|
|
label: 'Last sign-in',
|
|
value: (
|
|
<>
|
|
<span className="block">{formatDateTime(profile.lastSignInAt)}</span>
|
|
<span className="block text-xs font-normal text-muted-foreground">
|
|
{profile.lastSignInFrom}
|
|
</span>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
label: 'Password updated',
|
|
value: `${formatDate(profile.passwordUpdatedAt)} · ${formatRelative(profile.passwordUpdatedAt)}`,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<div className="flex items-start justify-between gap-3 rounded-xl border bg-background p-4">
|
|
<div className="flex items-start gap-3">
|
|
<IconTile icon={ShieldCheck} size="sm" tone={profile.twoFactorEnabled ? 'brand' : 'muted'} />
|
|
<div>
|
|
<p className="text-sm font-medium text-foreground">Two-factor authentication</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{profile.twoFactorEnabled ? 'Required at every sign-in' : 'Not enabled on this account'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={profile.twoFactorEnabled}
|
|
onChange={toggleTwoFactor}
|
|
label="Two-factor authentication"
|
|
/>
|
|
</div>
|
|
|
|
<Button variant="outline" className="w-full gap-2" onClick={() => setPasswordOpen(true)}>
|
|
<KeyRound className="h-4 w-4" />
|
|
Change password
|
|
</Button>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Active sessions
|
|
</p>
|
|
{sessions.some((s) => !s.current) ? (
|
|
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" onClick={revokeOtherSessions}>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
Sign out others
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
{sessions.length === 0 ? (
|
|
<EmptyState icon={Monitor} title="No active sessions" size="sm" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{sessions.map((session) => (
|
|
<li key={session.id} className="flex items-start justify-between gap-3 rounded-xl border p-3">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-foreground">{session.device}</span>
|
|
{session.current ? <Badge variant="success">This device</Badge> : null}
|
|
</div>
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
{session.browser} · {session.location}
|
|
</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{session.ip} · {formatRelative(session.lastActiveAt)}
|
|
</p>
|
|
</div>
|
|
{session.current ? null : (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-8 shrink-0 text-xs text-destructive hover:bg-red-50"
|
|
onClick={() => revokeSession(session)}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="Preferences" description="Language and what we notify you about">
|
|
<div className="space-y-5">
|
|
<div>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Languages className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
<p className="text-sm font-medium text-foreground">Console language</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Console language">
|
|
{(['en', 'am'] as const).map((locale) => (
|
|
<Button
|
|
key={locale}
|
|
type="button"
|
|
variant={activeLocale === locale ? 'default' : 'outline'}
|
|
aria-pressed={activeLocale === locale}
|
|
onClick={() => changeLocale(locale)}
|
|
>
|
|
{LOCALE_LABEL[locale]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<Bell className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
<p className="text-sm font-medium text-foreground">Notifications</p>
|
|
</div>
|
|
<ul className="space-y-3">
|
|
{NOTIFICATION_FIELDS.map((field) => (
|
|
<li key={field.key} className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-foreground">{field.label}</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p>
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={profile.notifications[field.key]}
|
|
onChange={(next) => toggleNotification(field.key, next)}
|
|
label={field.label}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-3 rounded-xl bg-brand-50 p-3">
|
|
<SlidersHorizontal className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
Preferences are stored against your account and apply to this console only.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</DetailSection>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Change password</DialogTitle>
|
|
<DialogDescription>
|
|
Choose a password of at least 8 characters. You stay signed in on this device.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="current-password">Current password</Label>
|
|
<Input
|
|
id="current-password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={passwordForm.current}
|
|
onChange={(e) => setPasswordForm((prev) => ({ ...prev, current: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="new-password">New password</Label>
|
|
<Input
|
|
id="new-password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={passwordForm.next}
|
|
onChange={(e) => setPasswordForm((prev) => ({ ...prev, next: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirm-password">Confirm new password</Label>
|
|
<Input
|
|
id="confirm-password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={passwordForm.confirm}
|
|
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirm: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setPasswordOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submitPasswordChange}>Update password</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|