refactor(account): redesign settings index, drop card stack

The index page had ~12 distinct card surfaces with three places of
nested cards-inside-cards, against PRODUCT.md's "elevation = lighter
surface + border" + "nested cards are always wrong" rules. Branding
appeared twice, Display Code lived in Identity but does invite work,
and Preferences got a full card for one dropdown.

Single column, max-w-3xl, no card chrome. Sections separated by
border-t rules + mono-uppercase section labels (existing house style):

- Header: inline-editable name + plan/status/owner/member-count info
  line. No card.
- Plan & usage: renewal date right-aligned in section header, three
  thin progress rows replace the 4-card usage stat grid, upgrade
  CTAs right-aligned at bottom.
- People (owner-only): invite form, unified members + pending invites
  list, display code as a quiet "share to invite during signup" line.
  Non-owners see a one-line "managed by your admin" instead of a card.
- Settings: dense route list (icon + title + summary + status pill +
  chevron). Profile above a thin divider; team-admin rows below,
  owner-gated. Branding row carries the Included/Plan-gated pill.
  Support & Feedback as a dim link at the bottom.
- Account actions: plain rows. Owner: Transfer + Delete. Non-owner:
  Leave. Destructive labels colored, no red box-of-doom.

Drops: Access & Security card (filler), Preferences card,
Settings Areas link grid, billing-card branding-status duplicate,
SettingsLinkCard helper. Default export format moves to Profile
Settings where it belongs (personal preference, not account).

856 -> 710 lines on the index. tsc, eslint, vite build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 23:57:29 -04:00
parent 0f90c0e199
commit 86120423da
2 changed files with 469 additions and 592 deletions

View File

@@ -2,9 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
AlertCircle, AlertCircle,
AlertTriangle,
ArrowRight, ArrowRight,
Building2,
Check, Check,
Clock, Clock,
Copy, Copy,
@@ -14,13 +12,11 @@ import {
Mail, Mail,
MessageSquareText, MessageSquareText,
Palette, Palette,
Pencil,
Plug, Plug,
RefreshCw, RefreshCw,
Server, Server,
Settings,
ShieldCheck,
UserCog, UserCog,
Users,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
@@ -36,46 +32,20 @@ import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { useSubscription } from '@/hooks/useSubscription' import { useSubscription } from '@/hooks/useSubscription'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { CheckoutButton } from '@/components/subscription/CheckoutButton' import { CheckoutButton } from '@/components/subscription/CheckoutButton'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
interface SettingsLinkCardProps { /* ── Building blocks ─────────────────────────────────────────────────────── */
to: string
icon: React.ReactNode
title: string
description: string
badge?: string
}
function SettingsLinkCard({ to, icon, title, description, badge }: SettingsLinkCardProps) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<Link <h2 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-text-muted">
to={to} {children}
className="group rounded-2xl border border-border bg-card p-5 transition-colors hover:border-border-hover" </h2>
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-elevated p-2 text-muted-foreground">{icon}</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{badge && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{badge}
</span>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</div>
</Link>
) )
} }
function UsageStat({ function UsageRow({
label, label,
current, current,
max, max,
@@ -86,35 +56,68 @@ function UsageStat({
}) { }) {
const isUnlimited = max === null const isUnlimited = max === null
const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100) const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100)
const isNearLimit = !isUnlimited && percentage >= 80
const isAtLimit = !isUnlimited && current >= max 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 ( return (
<div className="rounded-xl border border-border bg-card/50 p-4"> <div className="grid grid-cols-[8rem_1fr_auto] items-center gap-4 py-1.5">
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">{label}</p> <span className="text-sm text-muted-foreground">{label}</span>
<p <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( className={cn(
'mt-2 text-xl font-semibold', 'group flex items-center gap-3 py-2.5 -mx-2 px-2 rounded-md',
isAtLimit ? 'text-danger' : isNearLimit ? 'text-warning' : 'text-foreground' 'transition-colors hover:bg-card-hover'
)} )}
> >
{current} <span className="text-muted-foreground shrink-0">{icon}</span>
<span className="ml-1 text-sm font-normal text-muted-foreground"> <div className="min-w-0 flex-1">
/ {isUnlimited ? 'Unlimited' : max} <div className="text-sm font-medium text-foreground">{title}</div>
</span> <div className="text-xs text-muted-foreground truncate">{description}</div>
</p> </div>
{!isUnlimited && ( {status && (
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted"> <span
<div
className={cn( className={cn(
'h-full rounded-full transition-all', 'rounded-full px-2 py-0.5 text-[11px] font-medium shrink-0',
isAtLimit ? 'bg-danger' : isNearLimit ? 'bg-warning' : 'bg-primary' status.tone === 'positive' && 'bg-success-dim text-success',
status.tone === 'neutral' && 'bg-muted text-muted-foreground',
status.tone === 'warning' && 'bg-warning-dim text-warning'
)} )}
style={{ width: `${percentage}%` }} >
/> {status.label}
</div> </span>
)} )}
</div> <ArrowRight className="h-4 w-4 text-muted-foreground/50 transition-all group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
) )
} }
@@ -123,10 +126,15 @@ function formatShortDate(value: string | null | undefined) {
return new Date(value).toLocaleDateString() return new Date(value).toLocaleDateString()
} }
function planLabel(plan: string) {
return plan.charAt(0).toUpperCase() + plan.slice(1)
}
/* ── Page ────────────────────────────────────────────────────────────────── */
export function AccountSettingsPage() { export function AccountSettingsPage() {
const { isAccountOwner } = usePermissions() const { isAccountOwner } = usePermissions()
const { plan, limits, usage } = useSubscription() const { plan, limits, usage } = useSubscription()
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
const subscription = useAuthStore((s) => s.subscription) const subscription = useAuthStore((s) => s.subscription)
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const refreshUser = useAuthStore((s) => s.fetchUser) const refreshUser = useAuthStore((s) => s.fetchUser)
@@ -180,8 +188,11 @@ export function AccountSettingsPage() {
} }
const pendingInvites = useMemo(() => invites.filter((invite) => !invite.used_at), [invites]) const pendingInvites = useMemo(() => invites.filter((invite) => !invite.used_at), [invites])
const ownerMember = useMemo(() => members.find((member) => member.account_role === 'owner') ?? null, [members]) const ownerMember = useMemo(
() => members.find((member) => member.account_role === 'owner') ?? null,
[members]
)
const memberCount = members.length || (isAccountOwner ? 1 : undefined)
const handleCopyDisplayCode = async () => { const handleCopyDisplayCode = async () => {
if (!account?.display_code) return if (!account?.display_code) return
@@ -276,40 +287,27 @@ export function AccountSettingsPage() {
} }
const sub = subscription?.subscription 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 ( return (
<> <>
<PageMeta title="Account Settings" /> <PageMeta title="Account Settings" />
<div className="space-y-8"> <div className="mx-auto max-w-3xl space-y-10">
<div> {/* ── Header ─────────────────────────────────────────────────────── */}
<h1 className="text-2xl font-bold font-heading text-foreground">Account Management</h1> <header className="space-y-3">
<p className="mt-1 text-muted-foreground"> <div className="flex items-baseline gap-3">
Manage your account identity, billing, access, and workspace settings.
</p>
</div>
<div className="grid gap-6 xl:grid-cols-[1.25fr_0.95fr]">
<section className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Account Identity</h2>
</div>
<div className="mt-5 space-y-5">
<div>
<label className="block text-sm font-medium text-foreground">Account Name</label>
{isEditingName ? ( {isEditingName ? (
<div className="mt-2 flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="text" type="text"
value={editedName} value={editedName}
onChange={(e) => setEditedName(e.target.value)} onChange={(e) => setEditedName(e.target.value)}
className={cn( className={cn(inputClass, 'text-2xl font-bold font-heading py-1')}
'flex-1 rounded-md border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName() if (e.key === 'Enter') handleSaveName()
@@ -334,228 +332,147 @@ export function AccountSettingsPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="mt-2 flex items-center gap-2"> <>
<span className="text-sm text-foreground">{account?.name}</span> <h1 className="text-2xl font-bold font-heading text-foreground">{account?.name}</h1>
{isAccountOwner && ( {isAccountOwner && (
<button <button
onClick={() => setIsEditingName(true)} onClick={() => setIsEditingName(true)}
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Rename account"
> >
Edit <Pencil className="h-4 w-4" />
</button> </button>
)} )}
</div> </>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="grid gap-4 sm:grid-cols-2"> <span className="inline-flex items-center gap-1.5">
<div>
<label className="block text-sm font-medium text-foreground">Display Code</label>
<div className="mt-2 flex items-center gap-2">
<code className="rounded-md border border-border bg-card/50 px-3 py-2 font-mono text-sm text-foreground">
{account?.display_code}
</code>
<Button variant="secondary" size="icon-sm" onClick={handleCopyDisplayCode} title="Copy display code">
{copiedCode ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Share this code with teammates so they can join your account during registration.
</p>
</div>
<div>
<label className="block text-sm font-medium text-foreground">Account Owner</label>
<div className="mt-2 text-sm text-foreground">
{ownerMember ? ownerMember.name : user?.account_role === 'owner' ? user.email : 'Owner unavailable'}
</div>
<p className="mt-2 text-xs text-muted-foreground">
{ownerMember?.email ?? 'The owner manages billing, branding, integrations, and membership changes.'}
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-foreground">Created</label>
<p className="mt-2 text-sm text-muted-foreground">{formatShortDate(account?.created_at)}</p>
</div>
<div>
<label className="block text-sm font-medium text-foreground">Last updated</label>
<p className="mt-2 text-sm text-muted-foreground">{formatShortDate(account?.updated_at)}</p>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Crown className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Billing & Usage</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Monitor plan status, renewal timing, and current account limits.
</p>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium',
plan === 'free' && 'bg-muted text-muted-foreground',
plan === 'pro' && 'bg-accent-dim text-accent-text',
plan === 'team' && 'bg-accent-dim text-accent-text'
)}
>
<Crown className="h-3.5 w-3.5" /> <Crown className="h-3.5 w-3.5" />
{plan.charAt(0).toUpperCase() + plan.slice(1)} Plan {planLabel(plan)} plan
</span> </span>
{sub && ( {sub && (
<>
<span aria-hidden>·</span>
<span <span
className={cn( className={cn(
'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium', sub.status === 'active' && 'text-success',
sub.status === 'active' && 'bg-success-dim text-success', sub.status === 'trialing' && 'text-info',
sub.status === 'trialing' && 'bg-info-dim text-info', sub.status === 'past_due' && 'text-warning',
sub.status === 'past_due' && 'bg-warning-dim text-warning', sub.status === 'canceled' && 'text-danger'
sub.status === 'canceled' && 'bg-danger-dim text-danger',
sub.status === 'orphaned' && 'bg-muted text-muted-foreground'
)} )}
> >
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
</span> </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> </div>
<div className="mt-4 grid gap-4 sm:grid-cols-2"> {limits && usage ? (
<div className="rounded-xl border border-border bg-card/50 p-4"> <div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Renewal date</p> <UsageRow label="Flows" current={usage.tree_count} max={limits.max_trees} />
<p className="mt-2 text-sm text-foreground"> <UsageRow
{sub?.current_period_end ? new Date(sub.current_period_end).toLocaleDateString() : 'Not scheduled'} label="Sessions / month"
</p> current={usage.session_count_this_month}
</div> max={limits.max_sessions_per_month}
<div className="rounded-xl border border-border bg-card/50 p-4"> />
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Branding access</p> <UsageRow label="Seats" current={usage.user_count} max={limits.max_users} />
<p className="mt-2 text-sm text-foreground">
{limits?.custom_branding ? 'Included in your plan' : 'Upgrade required'}
</p>
</div>
</div>
{limits && usage && (
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<UsageStat label="Flows" current={usage.tree_count} max={limits.max_trees} />
<UsageStat label="Sessions / month" current={usage.session_count_this_month} max={limits.max_sessions_per_month} />
<UsageStat label="Seats" current={usage.user_count} max={limits.max_users} />
</div> </div>
) : (
<p className="text-sm text-muted-foreground">Plan limits unavailable.</p>
)} )}
{plan === 'free' && ( {plan !== 'team' && (
<div className="mt-5 flex flex-wrap gap-3"> <div className="flex flex-wrap justify-end gap-2 pt-2">
<CheckoutButton plan="pro" /> {plan === 'free' && <CheckoutButton plan="pro" />}
<CheckoutButton plan="team" /> <CheckoutButton plan="team" />
</div> </div>
)} )}
{plan === 'pro' && (
<div className="mt-5">
<CheckoutButton plan="team" />
</div>
)}
</div>
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Access & Security</h2>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Authentication</p>
<p className="mt-2 text-sm text-foreground">
{plan === 'team' ? 'Password auth available, SSO can be enabled.' : 'Password-based authentication is active.'}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Use profile settings to update your personal details and sign-in information.
</p>
</div>
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Single Sign-On</p>
<p className="mt-2 text-sm text-foreground">
{plan === 'team' ? 'Enterprise-ready setup available.' : 'Available on higher-tier account setups.'}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Contact support to configure SAML or OIDC for your organization.
</p>
</div>
</div>
{isAccountOwner && (
<div className="mt-5 rounded-xl border border-border bg-card/50 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-foreground">Need enterprise security controls?</p>
<p className="text-sm text-muted-foreground">
We can help enable SSO and align account security for larger teams.
</p>
</div>
<a
href="mailto:support@resolutionflow.com?subject=SSO%20Setup%20Request"
className={cn(
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
'bg-muted border border-border text-foreground transition-colors hover:border-border-hover'
)}
>
Contact Us
</a>
</div>
</div>
)}
</div>
</section> </section>
<aside className="space-y-6"> {/* ── People ─────────────────────────────────────────────────────── */}
{!isAccountOwner && ( {isAccountOwner ? (
<div className="rounded-2xl border border-border bg-card/50 p-5"> <section className="space-y-5 border-t border-border pt-8">
<p className="text-sm text-muted-foreground"> <SectionLabel>People</SectionLabel>
Membership, invites, branding, and integrations are managed by the account owner. Contact your admin to make changes.
</p>
</div>
)}
{isAccountOwner && ( <form onSubmit={handleInvite} className="flex flex-wrap items-center gap-2">
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6"> <input
<div className="flex items-center gap-2"> type="email"
<Users className="h-5 w-5 text-muted-foreground" /> placeholder="teammate@company.com"
<h2 className="text-lg font-semibold text-foreground">Membership</h2> value={inviteEmail}
</div> 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 ? ( {(members.length > 0 || pendingInvites.length > 0) && (
<p className="mt-4 text-sm text-muted-foreground">No team members yet. Use the invite form below to add your first teammate.</p> <ul className="divide-y divide-border">
) : (
<div className="mt-4 space-y-3">
{members.map((member) => ( {members.map((member) => (
<div key={member.id} className="rounded-xl border border-border bg-card/50 p-4"> <li key={`member-${member.id}`} className="flex items-center gap-3 py-3">
<div className="flex items-start justify-between gap-3"> <div className="min-w-0 flex-1">
<div>
<p className="text-sm font-medium text-foreground">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.email}</p>
<p className="mt-2 text-xs text-muted-foreground">
Last login: {formatShortDate(member.last_login)}
</p>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
{member.name}
</span>
{!member.is_active && ( {!member.is_active && (
<span className="rounded-full bg-danger-dim px-2 py-0.5 text-xs text-danger"> <span className="rounded-full bg-danger-dim px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-danger">
Inactive Inactive
</span> </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' ? ( {member.account_role === 'owner' ? (
<span className="rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-accent-text"> <span className="rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-accent-text">
owner Owner
</span> </span>
) : ( ) : (
<select <select
@@ -563,10 +480,15 @@ export function AccountSettingsPage() {
aria-label={`Role for ${member.name}`} aria-label={`Role for ${member.name}`}
onChange={async (e) => { onChange={async (e) => {
try { try {
const updated = await accountsApi.updateMemberRole(member.id, e.target.value) const updated = await accountsApi.updateMemberRole(
member.id,
e.target.value
)
setMembers((current) => setMembers((current) =>
current.map((entry) => current.map((entry) =>
entry.id === member.id ? { ...entry, account_role: updated.account_role } : entry entry.id === member.id
? { ...entry, account_role: updated.account_role }
: entry
) )
) )
toast.success(`Role updated to ${updated.account_role}`) toast.success(`Role updated to ${updated.account_role}`)
@@ -576,92 +498,51 @@ export function AccountSettingsPage() {
}} }}
className={cn( className={cn(
'rounded-md border border-border bg-card px-2 py-0.5 text-xs', 'rounded-md border border-border bg-card px-2 py-0.5 text-xs',
'text-foreground focus:border-primary focus:outline-hidden' 'text-foreground focus:border-primary/40 focus:outline-hidden'
)} )}
> >
<option value="engineer">engineer</option> <option value="engineer">Engineer</option>
<option value="viewer">viewer</option> <option value="viewer">Viewer</option>
</select> </select>
)} )}
{member.account_role !== 'owner' && ( {member.account_role !== 'owner' && (
<ConfirmButton <ConfirmButton
onConfirm={() => handleRemoveMember(member.id)} onConfirm={() => handleRemoveMember(member.id)}
confirmLabel="Remove?" confirmLabel="Remove?"
className="p-1 text-muted-foreground hover:text-danger" 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" confirmClassName="rounded px-1.5 py-0.5 text-xs font-medium text-danger bg-danger-dim"
aria-label={`Remove ${member.name}`} aria-label={`Remove ${member.name}`}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ConfirmButton> </ConfirmButton>
)} )}
</div> </li>
</div>
</div>
))} ))}
</div>
)}
</div>
)}
{isAccountOwner && (
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Invites</h2>
</div>
<form onSubmit={handleInvite} className="mt-4 space-y-3">
<input
type="email"
placeholder="Email address"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
required
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
<div className="flex gap-3">
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className={cn(
'min-w-[140px] rounded-md border border-border bg-card px-3 py-2',
'text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
<Button type="submit" disabled={!inviteEmail.trim()} loading={isInviting} className="flex-1">
{isInviting ? 'Sending...' : 'Send Invite'}
</Button>
</div>
</form>
{pendingInvites.length > 0 ? (
<div className="mt-5 space-y-3">
{pendingInvites.map((invite) => ( {pendingInvites.map((invite) => (
<div key={invite.id} className="rounded-xl border border-border bg-card/50 p-4"> <li key={`invite-${invite.id}`} className="flex items-center gap-3 py-3">
<div className="flex items-start justify-between gap-3"> <Mail className="h-4 w-4 shrink-0 text-muted-foreground" />
<div> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{invite.email}</p> <div className="text-sm font-medium text-foreground truncate">
<p className="mt-1 text-xs text-muted-foreground"> {invite.email}
{invite.expires_at
? `Expires ${new Date(invite.expires_at).toLocaleDateString()}`
: 'No expiration'}
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2.5 py-0.5 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} {invite.role}
</span> </span>
<button <button
onClick={() => handleResendInvite(invite.id)} onClick={() => handleResendInvite(invite.id)}
disabled={resendingId === invite.id} disabled={resendingId === invite.id}
className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-50" className="rounded p-1 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={`Resend invite to ${invite.email}`} aria-label={`Resend invite to ${invite.email}`}
> >
{resendingId === invite.id ? ( {resendingId === invite.id ? (
@@ -670,77 +551,122 @@ export function AccountSettingsPage() {
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
)} )}
</button> </button>
</div> </li>
</div>
</div>
))} ))}
</div> </ul>
) : (
<p className="mt-4 text-sm text-muted-foreground">No pending invites. Use the form above to invite teammates by email.</p>
)}
</div>
)} )}
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6"> {account?.display_code && (
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Settings className="h-5 w-5 text-muted-foreground" /> <span>Share to invite during signup:</span>
<h2 className="text-lg font-semibold text-foreground">Preferences</h2> <code className="rounded border border-border bg-code px-2 py-0.5 font-mono text-sm text-foreground">
</div> {account.display_code}
</code>
<div className="mt-4"> <button
<label htmlFor="export-format" className="block text-sm font-medium text-foreground"> onClick={handleCopyDisplayCode}
Default Export Format className="rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
</label> aria-label="Copy display code"
<p className="text-sm text-muted-foreground">
This format will be pre-selected when exporting sessions.
</p>
<select
id="export-format"
value={defaultExportFormat}
onChange={(e) => {
setDefaultExportFormat(e.target.value as 'markdown' | 'text' | 'html')
toast.success('Preference saved')
}}
className={cn(
'mt-2 block w-full rounded-md border border-border bg-card px-3 py-2',
'text-sm text-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
> >
<option value="markdown">Markdown (.md)</option> {copiedCode ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<option value="text">Plain Text (.txt)</option> </button>
<option value="html">HTML (.html)</option>
</select>
</div> </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"
/>
</div> </div>
<div className="rounded-2xl border border-danger/20 p-5 sm:p-6"> {isAccountOwner && (
<div className="flex items-center gap-2"> <div className="space-y-1 border-t border-border pt-4">
<AlertTriangle className="h-5 w-5 text-danger" /> <SettingsRow
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2> 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/categories"
icon={<FolderTree className="h-4 w-4" />}
title="Team categories"
description="Shared flow categories for your workspace"
/>
<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>
)}
<div className="mt-4 space-y-3"> <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 ? ( {isAccountOwner ? (
<> <>
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/40 p-4"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-medium text-foreground">Transfer Ownership</p> <p className="text-sm font-medium text-foreground">Transfer ownership</p>
<p className="text-xs text-muted-foreground">Make another member the account owner.</p> <p className="text-xs text-muted-foreground">
Move ownership of this account to another member.
</p>
</div> </div>
<Button <Button variant="secondary" size="sm" onClick={() => setShowTransferModal(true)}>
variant="secondary"
size="sm"
onClick={() => setShowTransferModal(true)}
className="border-warning/30 text-warning hover:bg-warning-dim"
>
Transfer Transfer
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/40 p-4"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-medium text-foreground">Delete Account</p> <p className="text-sm font-medium text-danger">Delete account</p>
<p className="text-xs text-muted-foreground">Permanently delete your account and all data.</p> <p className="text-xs text-muted-foreground">
Permanently delete the account and all data. Cannot be undone.
</p>
</div> </div>
<Button variant="destructive" size="sm" onClick={() => setShowDeleteModal(true)}> <Button variant="destructive" size="sm" onClick={() => setShowDeleteModal(true)}>
Delete Delete
@@ -748,90 +674,18 @@ export function AccountSettingsPage() {
</div> </div>
</> </>
) : ( ) : (
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/40 p-4"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-medium text-foreground">Leave Account</p> <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> <p className="text-xs text-muted-foreground">
Leave this account and create a personal one.
</p>
</div> </div>
<Button variant="destructive" size="sm" onClick={() => setShowLeaveModal(true)}> <Button variant="destructive" size="sm" onClick={() => setShowLeaveModal(true)}>
Leave Leave
</Button> </Button>
</div> </div>
)} )}
</div>
</div>
</aside>
</div>
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground">Settings Areas</h2>
<p className="text-sm text-muted-foreground">
Common account management actions, organized the way most SaaS teams expect to find them.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<SettingsLinkCard
to="/account/profile"
icon={<UserCog className="h-5 w-5" />}
title="Profile Settings"
description="Update your name, email, and personal details."
/>
{isAccountOwner && (
<SettingsLinkCard
to="/account/branding"
icon={<Palette className="h-5 w-5" />}
title="Branding"
description="Customize logo, accent color, and company name."
badge={limits?.custom_branding ? 'Included' : 'Plan gated'}
/>
)}
{isAccountOwner && (
<SettingsLinkCard
to="/account/integrations"
icon={<Plug className="h-5 w-5" />}
title="Integrations"
description="Connect PSA and other external systems for your team."
/>
)}
{isAccountOwner && (
<SettingsLinkCard
to="/account/chat-retention"
icon={<Clock className="h-5 w-5" />}
title="Chat Retention"
description="Control conversation retention and assistant data lifecycle."
/>
)}
{isAccountOwner && (
<SettingsLinkCard
to="/account/categories"
icon={<FolderTree className="h-5 w-5" />}
title="Team Categories"
description="Manage shared flow categories for your workspace."
/>
)}
{isAccountOwner && (
<SettingsLinkCard
to="/account/target-lists"
icon={<Server className="h-5 w-5" />}
title="Target Lists"
description="Maintain saved server and device lists for the team."
/>
)}
<SettingsLinkCard
to="/feedback"
icon={<MessageSquareText className="h-5 w-5" />}
title="Support & Feedback"
description="Report bugs, request features, or share product feedback."
/>
</div>
</section> </section>
{showTransferModal && ( {showTransferModal && (

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react' import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import type { UserUpdate } from '@/types' import type { UserUpdate } from '@/types'
@@ -16,6 +17,7 @@ const inputClass = cn(
export function ProfileSettingsPage() { export function ProfileSettingsPage() {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const fetchUser = useAuthStore((s) => s.fetchUser) const fetchUser = useAuthStore((s) => s.fetchUser)
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
const [name, setName] = useState(user?.name ?? '') const [name, setName] = useState(user?.name ?? '')
const [email, setEmail] = useState(user?.email ?? '') const [email, setEmail] = useState(user?.email ?? '')
@@ -120,6 +122,27 @@ export function ProfileSettingsPage() {
</select> </select>
</div> </div>
{/* Default export format — saved on change, not via Save Changes */}
<div>
<label htmlFor="profile-export-format" className="block text-sm font-medium text-foreground">
Default export format
</label>
<p className="text-xs text-muted-foreground">Pre-selected when exporting sessions.</p>
<select
id="profile-export-format"
value={defaultExportFormat}
onChange={(e) => {
setDefaultExportFormat(e.target.value as 'markdown' | 'text' | 'html')
toast.success('Preference saved')
}}
className={inputClass}
>
<option value="markdown">Markdown (.md)</option>
<option value="text">Plain text (.txt)</option>
<option value="html">HTML (.html)</option>
</select>
</div>
{error && ( {error && (
<div className="flex items-center gap-2 text-sm text-rose-500"> <div className="flex items-center gap-2 text-sm text-rose-500">
<AlertCircle className="h-4 w-4 shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0" />