Files
resolutionflow/frontend/src/pages/AccountSettingsPage.tsx
Michael Chihlas a332a9ebab 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>
2026-03-07 20:00:38 -05:00

749 lines
29 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
import { LeaveAccountModal } from '@/components/account/LeaveAccountModal'
import { DeleteAccountModal } from '@/components/account/DeleteAccountModal'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions'
import { useSubscription } from '@/hooks/useSubscription'
import { useAuthStore } from '@/store/authStore'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { CheckoutButton } from '@/components/subscription/CheckoutButton'
import { toast } from '@/lib/toast'
export function AccountSettingsPage() {
const { isAccountOwner } = usePermissions()
const { plan, limits, usage } = useSubscription()
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
const subscription = useAuthStore((s) => s.subscription)
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)
// Account name editing
const [isEditingName, setIsEditingName] = useState(false)
const [editedName, setEditedName] = useState('')
const [isSavingName, setIsSavingName] = useState(false)
// Modals
const [showTransferModal, setShowTransferModal] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
// Invite form
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState('engineer')
const [isInviting, setIsInviting] = useState(false)
const [inviteError, setInviteError] = useState<string | null>(null)
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
useEffect(() => {
loadData()
}, [])
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 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)
} catch (err) {
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)
setInviteError(null)
setInviteSuccess(null)
try {
await accountsApi.createInvite({ email: inviteEmail.trim(), role: inviteRole })
setInviteSuccess(`Invitation sent to ${inviteEmail}`)
setInviteEmail('')
// Refresh invites list
const invitesData = await accountsApi.getInvites()
setInvites(invitesData)
} catch (err) {
setInviteError('Failed to send invitation')
console.error(err)
} finally {
setIsInviting(false)
}
}
const [resendingId, setResendingId] = useState<string | null>(null)
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(members.filter((m) => m.id !== userId))
} catch (err) {
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-red-400/20 bg-red-400/10 p-4 text-red-400">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
</div>
</div>
)
}
const sub = subscription?.subscription
return (
<div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Building2 className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Account Settings</h1>
</div>
<p className="mt-2 text-muted-foreground">
Manage your account, subscription, and team
</p>
</div>
<div className="max-w-3xl space-y-6">
{/* Account Info Section */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground">Account Information</h2>
<div className="mt-4 space-y-4">
{/* Account Name */}
<div>
<label className="block text-sm font-medium text-foreground">
Account Name
</label>
{isEditingName ? (
<div className="mt-1 flex items-center gap-2">
<input
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
className={cn(
'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
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName()
if (e.key === 'Escape') {
setEditedName(account?.name ?? '')
setIsEditingName(false)
}
}}
/>
<button
onClick={handleSaveName}
disabled={isSavingName}
className={cn(
'rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 p-2',
'hover:opacity-90 disabled:opacity-50'
)}
>
{isSavingName ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
</button>
<button
onClick={() => {
setEditedName(account?.name ?? '')
setIsEditingName(false)
}}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="mt-1 flex items-center gap-2">
<span className="text-sm text-foreground">{account?.name}</span>
{isAccountOwner && (
<button
onClick={() => setIsEditingName(true)}
className="text-xs text-foreground hover:underline"
>
Edit
</button>
)}
</div>
)}
</div>
{/* Display Code */}
<div>
<label className="block text-sm font-medium text-foreground">
Display Code
</label>
<p className="mt-1 text-sm font-mono text-muted-foreground">
{account?.display_code}
</p>
</div>
</div>
</div>
{/* Subscription Section */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<h2 className="text-lg font-semibold text-foreground">Subscription</h2>
<div className="mt-4 space-y-4">
{/* Plan & Status */}
<div className="flex 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-accent text-muted-foreground',
plan === 'pro' && 'bg-accent text-foreground',
plan === 'team' && 'bg-accent text-foreground'
)}
>
<Crown className="h-3.5 w-3.5" />
{plan.charAt(0).toUpperCase() + plan.slice(1)} Plan
</span>
{sub && (
<span
className={cn(
'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium',
sub.status === 'active' && 'bg-green-500/10 text-emerald-400',
sub.status === 'trialing' && 'bg-blue-500/10 text-blue-400',
sub.status === 'past_due' && 'bg-yellow-500/10 text-yellow-400',
sub.status === 'canceled' && 'bg-red-400/10 text-red-400',
sub.status === 'orphaned' && 'bg-accent text-muted-foreground'
)}
>
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
</span>
)}
</div>
{sub?.current_period_end && (
<p className="text-sm text-muted-foreground">
Current period ends: {new Date(sub.current_period_end).toLocaleDateString()}
</p>
)}
{/* Usage Stats */}
{limits && usage && (
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<UsageStat
label="Trees"
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="Team members"
current={usage.user_count}
max={limits.max_users}
/>
</div>
)}
{/* Upgrade buttons */}
{plan === 'free' && (
<div className="mt-4 flex gap-3">
<CheckoutButton plan="pro" />
<CheckoutButton plan="team" />
</div>
)}
{plan === 'pro' && (
<div className="mt-4">
<CheckoutButton plan="team" />
</div>
)}
</div>
</div>
{/* Team Members Section (owners only) */}
{isAccountOwner && (
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Team Members</h2>
</div>
{members.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground">No team members yet.</p>
) : (
<div className="mt-4 divide-y divide-border">
{members.map((member) => (
<div
key={member.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div>
<p className="text-sm font-medium text-foreground">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-3">
{member.account_role === 'owner' ? (
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent text-foreground">
owner
</span>
) : (
<select
value={member.account_role}
onChange={async (e) => {
try {
const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
setMembers(members.map((m) => m.id === member.id ? { ...m, account_role: updated.account_role } : m))
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 focus:outline-hidden'
)}
>
<option value="engineer">engineer</option>
<option value="viewer">viewer</option>
</select>
)}
{!member.is_active && (
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400">
Inactive
</span>
)}
{member.account_role !== 'owner' && (
<button
onClick={() => handleRemoveMember(member.id)}
className="text-muted-foreground hover:text-red-400"
title="Remove member"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Invite Member Section (owners only) */}
{isAccountOwner && (
<div className="bg-card border border-border rounded-xl p-4 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">Invite Member</h2>
</div>
<form onSubmit={handleInvite} className="mt-4 space-y-3">
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
placeholder="Email address"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
required
className={cn(
'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'
)}
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className={cn(
'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={isInviting || !inviteEmail.trim()}
className={cn(
'rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
'hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isInviting ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Sending...
</span>
) : (
'Send Invite'
)}
</button>
</div>
{inviteError && (
<p className="text-sm text-red-400">{inviteError}</p>
)}
{inviteSuccess && (
<p className="text-sm text-emerald-400">{inviteSuccess}</p>
)}
</form>
{/* Pending Invites */}
{invites.length > 0 && (
<div className="mt-6">
<h3 className="text-sm font-medium text-foreground">Pending Invites</h3>
<div className="mt-2 divide-y divide-border">
{invites
.filter((inv) => !inv.used_at)
.map((invite) => (
<div
key={invite.id}
className="flex items-center justify-between py-2"
>
<div>
<p className="text-sm text-foreground">{invite.email}</p>
<p className="text-xs text-muted-foreground">
{invite.expires_at
? `Expires ${new Date(invite.expires_at).toLocaleDateString()}`
: 'No expiration'}
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs text-muted-foreground">
{invite.role}
</span>
<button
onClick={() => handleResendInvite(invite.id)}
disabled={resendingId === invite.id}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
title="Resend invite"
>
{resendingId === invite.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Profile Settings Link */}
<Link
to="/account/profile"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<UserCog className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Profile Settings</h2>
<p className="text-sm text-muted-foreground">Update your name, email, and personal details</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Team Categories Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/categories"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<FolderTree className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Team Categories</h2>
<p className="text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Target Lists Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/target-lists"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Target Lists</h2>
<p className="text-sm text-muted-foreground">Saved server lists for maintenance flow batch launching</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Chat Retention Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/chat-retention"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Chat Retention</h2>
<p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Feedback Link (all users) */}
<Link
to="/feedback"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<MessageSquareText className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Send Feedback</h2>
<p className="text-sm text-muted-foreground">Report bugs, request features, or share your thoughts</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Preferences Section */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Preferences</h2>
</div>
<div className="mt-4">
<label
htmlFor="export-format"
className="block text-sm font-medium text-foreground"
>
Default Export Format
</label>
<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 max-w-xs rounded-xl 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>
<option value="text">Plain Text (.txt)</option>
<option value="html">HTML (.html)</option>
</select>
</div>
</div>
{/* Danger Zone */}
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="h-5 w-5 text-rose-500" />
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
</div>
<div className="space-y-3">
{isAccountOwner ? (
<>
<div className="flex items-center justify-between">
<div>
<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>
</div>
<button
onClick={() => setShowTransferModal(true)}
className={cn(
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
'border border-amber-500/30 text-amber-400 hover:bg-amber-500/10'
)}
>
Transfer
</button>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<div>
<p className="text-sm font-medium text-foreground">Delete Account</p>
<p className="text-xs text-muted-foreground">Permanently delete your account and all data</p>
</div>
<button
onClick={() => setShowDeleteModal(true)}
className={cn(
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
)}
>
Delete
</button>
</div>
</>
) : (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Leave Account</p>
<p className="text-xs text-muted-foreground">Leave this account and create a personal one</p>
</div>
<button
onClick={() => setShowLeaveModal(true)}
className={cn(
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
)}
>
Leave
</button>
</div>
)}
</div>
</div>
</div>
{/* Modals */}
{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>
)
}
/** Small helper component for usage stat display */
function UsageStat({
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 isNearLimit = !isUnlimited && percentage >= 80
const isAtLimit = !isUnlimited && current >= max
return (
<div className="rounded-md border border-border bg-card p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p
className={cn(
'mt-1 text-lg font-semibold',
isAtLimit ? 'text-red-400' : isNearLimit ? 'text-yellow-400' : 'text-foreground'
)}
>
{current}
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {isUnlimited ? 'Unlimited' : max}
</span>
</p>
{!isUnlimited && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-accent">
<div
className={cn(
'h-full rounded-full transition-all',
isAtLimit ? 'bg-red-400' : isNearLimit ? 'bg-yellow-500' : 'bg-primary'
)}
style={{ width: `${percentage}%` }}
/>
</div>
)}
</div>
)
}
export default AccountSettingsPage