Files
resolutionflow/frontend/src/pages/AccountSettingsPage.tsx
Michael Chihlas 1b7aedb204 feat(l1): admin L1 category settings page + route + settings card
New owner-gated pages/account/L1CategoriesPage.tsx: checkbox list of available
categories toggling enabled via l1Api.getCategories/setCategories, plus a read-only
'always excluded (safety)' hard-floor list. Registered lazy route /account/l1-categories
(ProtectedRoute requiredRole=owner) and an 'L1 AI build categories' card in the
AccountSettingsPage owner section. tsc -b + eslint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:43:59 -04:00

754 lines
29 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
AlertCircle,
ArrowRight,
Check,
Clock,
Copy,
CreditCard,
Crown,
FolderTree,
Loader2,
Mail,
MessageSquareText,
Palette,
Pencil,
Plug,
RefreshCw,
Server,
Shield,
Wand2,
UserCog,
X,
} from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountInvite, AccountMember } from '@/types'
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
import { LeaveAccountModal } from '@/components/account/LeaveAccountModal'
import { DeleteAccountModal } from '@/components/account/DeleteAccountModal'
import { Button } from '@/components/ui/Button'
import { ConfirmButton } from '@/components/common/ConfirmButton'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions'
import { useSubscription } from '@/hooks/useSubscription'
import { SeatCounterWidget } from '@/components/admin/SeatCounterWidget'
import { useAuthStore } from '@/store/authStore'
import { CheckoutButton } from '@/components/subscription/CheckoutButton'
import { toast } from '@/lib/toast'
/* ── Building blocks ─────────────────────────────────────────────────────── */
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-text-muted">
{children}
</h2>
)
}
function UsageRow({
label,
current,
max,
}: {
label: string
current: number
max: number | null
}) {
const isUnlimited = max === null
const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100)
const isAtLimit = !isUnlimited && current >= max
const isNearLimit = !isUnlimited && percentage >= 80
const numeralColor = isAtLimit
? 'text-danger'
: isNearLimit
? 'text-warning'
: 'text-foreground'
const barColor = isAtLimit ? 'bg-danger' : isNearLimit ? 'bg-warning' : 'bg-primary'
return (
<div className="grid grid-cols-[8rem_1fr_auto] items-center gap-4 py-1.5">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="h-1 overflow-hidden rounded-full bg-muted">
{!isUnlimited && (
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${percentage}%` }} />
)}
</div>
<span className="text-sm tabular-nums">
<span className={numeralColor}>{current}</span>
<span className="text-muted-foreground"> / {isUnlimited ? '∞' : max}</span>
</span>
</div>
)
}
interface SettingsRowProps {
to: string
icon: React.ReactNode
title: string
description: string
status?: { label: string; tone: 'positive' | 'neutral' | 'warning' }
}
function SettingsRow({ to, icon, title, description, status }: SettingsRowProps) {
return (
<Link
to={to}
className={cn(
'group flex items-center gap-3 py-2.5 -mx-2 px-2 rounded-md',
'transition-colors hover:bg-card-hover'
)}
>
<span className="text-muted-foreground shrink-0">{icon}</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{title}</div>
<div className="text-xs text-muted-foreground truncate">{description}</div>
</div>
{status && (
<span
className={cn(
'rounded-full px-2 py-0.5 text-[11px] font-medium shrink-0',
status.tone === 'positive' && 'bg-success-dim text-success',
status.tone === 'neutral' && 'bg-muted text-muted-foreground',
status.tone === 'warning' && 'bg-warning-dim text-warning'
)}
>
{status.label}
</span>
)}
<ArrowRight className="h-4 w-4 text-muted-foreground/50 transition-all group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
)
}
function formatShortDate(value: string | null | undefined) {
if (!value) return 'Never'
return new Date(value).toLocaleDateString()
}
function planLabel(plan: string) {
return plan.charAt(0).toUpperCase() + plan.slice(1)
}
/* ── Page ────────────────────────────────────────────────────────────────── */
export function AccountSettingsPage() {
const { isAccountOwner } = usePermissions()
const { plan, limits, usage } = useSubscription()
const subscription = useAuthStore((s) => s.subscription)
const user = useAuthStore((s) => s.user)
const refreshUser = useAuthStore((s) => s.fetchUser)
const [account, setAccount] = useState<Account | null>(null)
const [members, setMembers] = useState<AccountMember[]>([])
const [invites, setInvites] = useState<AccountInvite[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedCode, setCopiedCode] = useState(false)
const [isEditingName, setIsEditingName] = useState(false)
const [editedName, setEditedName] = useState('')
const [isSavingName, setIsSavingName] = useState(false)
const [showTransferModal, setShowTransferModal] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState('engineer')
const [isInviting, setIsInviting] = useState(false)
const [resendingId, setResendingId] = useState<string | null>(null)
useEffect(() => {
loadData()
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- initial account load; mutations call loadData explicitly
const loadData = async () => {
setIsLoading(true)
setError(null)
try {
const accountData = await accountsApi.getMyAccount()
setAccount(accountData)
setEditedName(accountData.name)
if (isAccountOwner) {
const [membersData, invitesData] = await Promise.all([
accountsApi.getMembers(),
accountsApi.getInvites(),
])
setMembers(membersData)
setInvites(invitesData)
}
} catch (err) {
setError('Failed to load account information')
console.error(err)
} finally {
setIsLoading(false)
}
}
const pendingInvites = useMemo(() => invites.filter((invite) => !invite.used_at), [invites])
const ownerMember = useMemo(
() => members.find((member) => member.account_role === 'owner') ?? null,
[members]
)
const memberCount = members.length || (isAccountOwner ? 1 : undefined)
const handleCopyDisplayCode = async () => {
if (!account?.display_code) return
await navigator.clipboard.writeText(account.display_code)
setCopiedCode(true)
toast.success('Display code copied')
setTimeout(() => setCopiedCode(false), 2000)
}
const handleSaveName = async () => {
if (!editedName.trim() || editedName === account?.name) {
setIsEditingName(false)
return
}
setIsSavingName(true)
try {
const updated = await accountsApi.updateMyAccount({ name: editedName.trim() })
setAccount(updated)
setIsEditingName(false)
toast.success('Account name updated')
await refreshUser()
} catch (err) {
toast.error('Failed to update account name')
console.error('Failed to update account name:', err)
} finally {
setIsSavingName(false)
}
}
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault()
if (!inviteEmail.trim()) return
setIsInviting(true)
try {
await accountsApi.createInvite({ email: inviteEmail.trim(), role: inviteRole })
toast.success(`Invitation sent to ${inviteEmail}`)
setInviteEmail('')
const invitesData = await accountsApi.getInvites()
setInvites(invitesData)
} catch (err) {
const resp = (err as {
response?: {
status?: number
data?: { detail?: { code?: string; role?: string; current?: number; limit?: number } }
}
}).response
if (resp?.status === 402 && resp?.data?.detail?.code === 'seat_limit_exceeded') {
const d = resp.data.detail
const label = d.role === 'l1_tech' ? 'L1' : 'Engineer'
toast.warning(
`${label} seats full: ${d.current}/${d.limit}. Upgrade your plan to add more.`,
)
} else {
toast.error('Failed to send invitation')
console.error(err)
}
} finally {
setIsInviting(false)
}
}
const handleResendInvite = async (inviteId: string) => {
setResendingId(inviteId)
try {
await accountsApi.resendInvite(inviteId)
toast.success('Invite resent with a new code')
const invitesData = await accountsApi.getInvites()
setInvites(invitesData)
} catch {
toast.error('Failed to resend invite')
} finally {
setResendingId(null)
}
}
const handleRemoveMember = async (userId: string) => {
try {
await accountsApi.removeMember(userId)
setMembers((current) => current.filter((member) => member.id !== userId))
toast.success('Member removed')
await refreshUser()
} catch (err) {
toast.error('Failed to remove member')
console.error('Failed to remove member:', err)
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Spinner />
</div>
)
}
if (error) {
return (
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-danger">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
</div>
</div>
)
}
const sub = subscription?.subscription
const ownerName = ownerMember?.name ?? (user?.account_role === 'owner' ? user.name : null)
const inputClass = cn(
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground',
'focus:border-primary/40 focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)
return (
<>
<PageMeta title="Account Settings" />
<div className="mx-auto max-w-3xl space-y-10">
{/* ── Header ─────────────────────────────────────────────────────── */}
<header className="space-y-3">
<div className="flex items-baseline gap-3">
{isEditingName ? (
<div className="flex items-center gap-2">
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
className={cn(inputClass, 'text-2xl font-bold font-heading py-1')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName()
if (e.key === 'Escape') {
setEditedName(account?.name ?? '')
setIsEditingName(false)
}
}}
/>
<Button onClick={handleSaveName} loading={isSavingName} size="icon-sm">
<Check className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon-sm"
onClick={() => {
setEditedName(account?.name ?? '')
setIsEditingName(false)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<>
<h1 className="text-2xl font-bold font-heading text-foreground">{account?.name}</h1>
{isAccountOwner && (
<button
onClick={() => setIsEditingName(true)}
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Rename account"
>
<Pencil className="h-4 w-4" />
</button>
)}
</>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Crown className="h-3.5 w-3.5" />
{planLabel(plan)} plan
</span>
{sub && (
<>
<span aria-hidden>·</span>
<span
className={cn(
sub.status === 'active' && 'text-success',
sub.status === 'trialing' && 'text-info',
sub.status === 'past_due' && 'text-warning',
sub.status === 'canceled' && 'text-danger'
)}
>
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
</span>
</>
)}
{ownerName && (
<>
<span aria-hidden>·</span>
<span>Owned by {ownerName}</span>
</>
)}
{memberCount !== undefined && (
<>
<span aria-hidden>·</span>
<span>
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</span>
</>
)}
<>
<span aria-hidden>·</span>
<span>Created {formatShortDate(account?.created_at)}</span>
</>
</div>
</header>
{/* ── Plan & Usage ───────────────────────────────────────────────── */}
<section className="space-y-4 border-t border-border pt-8">
<div className="flex items-baseline justify-between">
<SectionLabel>Plan & usage</SectionLabel>
<span className="text-xs text-muted-foreground">
{sub?.current_period_end
? `Renews ${new Date(sub.current_period_end).toLocaleDateString()}`
: 'No renewal scheduled'}
</span>
</div>
{limits && usage ? (
<div className="space-y-1">
<UsageRow label="Flows" current={usage.tree_count} max={limits.max_trees} />
<UsageRow
label="Sessions / month"
current={usage.session_count_this_month}
max={limits.max_sessions_per_month}
/>
{(() => {
const seatCount = usage.user_count ?? (isAccountOwner ? members.length : null)
return seatCount !== null ? (
<UsageRow label="Seats" current={seatCount} max={limits.max_users} />
) : null
})()}
</div>
) : (
<p className="text-sm text-muted-foreground">Plan limits unavailable.</p>
)}
{plan !== 'enterprise' && (
<div className="flex flex-wrap justify-end gap-2 pt-2">
{plan === 'free' && <CheckoutButton plan="pro" />}
<CheckoutButton plan="enterprise" />
</div>
)}
</section>
{/* ── People ─────────────────────────────────────────────────────── */}
{isAccountOwner ? (
<section className="space-y-5 border-t border-border pt-8">
<SectionLabel>People</SectionLabel>
<SeatCounterWidget />
<form onSubmit={handleInvite} className="flex flex-wrap items-center gap-2">
<input
type="email"
placeholder="teammate@company.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
required
className={cn(inputClass, 'flex-1 min-w-[14rem]')}
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
aria-label="Invite role"
className={inputClass}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
<Button type="submit" disabled={!inviteEmail.trim()} loading={isInviting}>
{isInviting ? 'Sending…' : 'Invite'}
</Button>
</form>
{(members.length > 0 || pendingInvites.length > 0) && (
<ul className="divide-y divide-border">
{members.map((member) => (
<li key={`member-${member.id}`} className="flex items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
{member.name}
</span>
{!member.is_active && (
<span className="rounded-full bg-danger-dim px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-danger">
Inactive
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">
{member.email}
{member.last_login && (
<span> · Last seen {formatShortDate(member.last_login)}</span>
)}
</div>
</div>
{member.account_role === 'owner' ? (
<span className="rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-accent-text">
Owner
</span>
) : (
<select
value={member.account_role}
aria-label={`Role for ${member.name}`}
onChange={async (e) => {
try {
const updated = await accountsApi.updateMemberRole(
member.id,
e.target.value
)
setMembers((current) =>
current.map((entry) =>
entry.id === member.id
? { ...entry, account_role: updated.account_role }
: entry
)
)
toast.success(`Role updated to ${updated.account_role}`)
} catch {
toast.error('Failed to update role')
}
}}
className={cn(
'rounded-md border border-border bg-card px-2 py-0.5 text-xs',
'text-foreground focus:border-primary/40 focus:outline-hidden'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
)}
{member.account_role !== 'owner' && (
<ConfirmButton
onConfirm={() => handleRemoveMember(member.id)}
confirmLabel="Remove?"
className="rounded p-1 text-muted-foreground hover:text-danger"
confirmClassName="rounded px-1.5 py-0.5 text-xs font-medium text-danger bg-danger-dim"
aria-label={`Remove ${member.name}`}
>
<X className="h-4 w-4" />
</ConfirmButton>
)}
</li>
))}
{pendingInvites.map((invite) => (
<li key={`invite-${invite.id}`} className="flex items-center gap-3 py-3">
<Mail className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">
{invite.email}
</div>
<div className="text-xs text-muted-foreground">
Pending
{invite.expires_at && (
<span>
{' '}
· Expires {new Date(invite.expires_at).toLocaleDateString()}
</span>
)}
</div>
</div>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{invite.role}
</span>
<button
onClick={() => handleResendInvite(invite.id)}
disabled={resendingId === invite.id}
className="rounded p-1 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={`Resend invite to ${invite.email}`}
>
{resendingId === invite.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</li>
))}
</ul>
)}
{account?.display_code && (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>Share to invite during signup:</span>
<code className="rounded border border-border bg-code px-2 py-0.5 font-mono text-sm text-foreground">
{account.display_code}
</code>
<button
onClick={handleCopyDisplayCode}
className="rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Copy display code"
>
{copiedCode ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)}
</section>
) : (
<section className="border-t border-border pt-8">
<SectionLabel>People</SectionLabel>
<p className="mt-3 text-sm text-muted-foreground">
Membership and invites are managed by the account owner
{ownerName && <span> ({ownerName})</span>}. Contact your admin to make changes.
</p>
</section>
)}
{/* ── Settings ───────────────────────────────────────────────────── */}
<section className="space-y-4 border-t border-border pt-8">
<SectionLabel>Settings</SectionLabel>
<div className="space-y-1">
<SettingsRow
to="/account/profile"
icon={<UserCog className="h-4 w-4" />}
title="Profile"
description="Your name, email, and personal preferences"
/>
<SettingsRow
to="/account/billing"
icon={<CreditCard className="h-4 w-4" />}
title="Billing"
description="Subscription, payment method, and invoices"
/>
</div>
{isAccountOwner && (
<div className="space-y-1 border-t border-border pt-4">
<SettingsRow
to="/account/branding"
icon={<Palette className="h-4 w-4" />}
title="Branding"
description="Logo, accent color, and company name"
status={
limits?.custom_branding
? { label: 'Included', tone: 'positive' }
: { label: 'Plan gated', tone: 'warning' }
}
/>
<SettingsRow
to="/account/integrations"
icon={<Plug className="h-4 w-4" />}
title="Integrations"
description="Connect PSA tools and external systems"
/>
<SettingsRow
to="/account/chat-retention"
icon={<Clock className="h-4 w-4" />}
title="Chat retention"
description="Conversation retention and assistant data lifecycle"
/>
<SettingsRow
to="/account/security"
icon={<Shield className="h-4 w-4" />}
title="Session security"
description="Session-expiration policy and active sessions"
/>
<SettingsRow
to="/account/categories"
icon={<FolderTree className="h-4 w-4" />}
title="Team categories"
description="Shared flow categories for your workspace"
/>
<SettingsRow
to="/account/l1-categories"
icon={<Wand2 className="h-4 w-4" />}
title="L1 AI build categories"
description="Which problem types the L1 assistant may build trees for"
/>
<SettingsRow
to="/account/target-lists"
icon={<Server className="h-4 w-4" />}
title="Target lists"
description="Saved server and device lists for the team"
/>
</div>
)}
<div className="pt-2">
<Link
to="/feedback"
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<MessageSquareText className="h-3.5 w-3.5" />
Need help? Send feedback or report a bug
</Link>
</div>
</section>
{/* ── Account actions (transfer / delete / leave) ────────────────── */}
<section className="border-t border-border pt-8 space-y-3">
{isAccountOwner ? (
<>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">Transfer ownership</p>
<p className="text-xs text-muted-foreground">
Move ownership of this account to another member.
</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setShowTransferModal(true)}>
Transfer
</Button>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-danger">Delete account</p>
<p className="text-xs text-muted-foreground">
Permanently delete the account and all data. Cannot be undone.
</p>
</div>
<Button variant="destructive" size="sm" onClick={() => setShowDeleteModal(true)}>
Delete
</Button>
</div>
</>
) : (
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-danger">Leave account</p>
<p className="text-xs text-muted-foreground">
Leave this account and create a personal one.
</p>
</div>
<Button variant="destructive" size="sm" onClick={() => setShowLeaveModal(true)}>
Leave
</Button>
</div>
)}
</section>
{showTransferModal && (
<TransferOwnershipModal
members={members}
onClose={() => setShowTransferModal(false)}
onTransferred={() => {
setShowTransferModal(false)
loadData()
}}
/>
)}
{showLeaveModal && account && (
<LeaveAccountModal accountName={account.name} onClose={() => setShowLeaveModal(false)} />
)}
{showDeleteModal && <DeleteAccountModal onClose={() => setShowDeleteModal(false)} />}
</div>
</>
)
}
export default AccountSettingsPage