Public pages (Login, Register, Forgot/Reset Password, Verify Email, Survey Thank You) get descriptions for SEO. Authenticated pages (Dashboard, Flow Library, My Flows, Session History, AI Assistant, Account Settings, Step Library, My Shares, Feedback, Guides) get proper tab titles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
733 lines
28 KiB
TypeScript
733 lines
28 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 { PageMeta } from '@/components/common/PageMeta'
|
|
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 { Button } from '@/components/ui/Button'
|
|
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 (
|
|
<>
|
|
<PageMeta title="Account Settings" />
|
|
<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}
|
|
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>
|
|
) : (
|
|
<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={!inviteEmail.trim()}
|
|
loading={isInviting}
|
|
>
|
|
{isInviting ? 'Sending...' : '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">→</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">→</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">→</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">→</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">→</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
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowTransferModal(true)}
|
|
className="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
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setShowDeleteModal(true)}
|
|
>
|
|
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
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setShowLeaveModal(true)}
|
|
>
|
|
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
|