feat: update frontend for account-based subscriptions
Replace all team_id/team_admin references with account_id/owner across types, store, hooks, API clients, components, and pages. Add new AccountSettingsPage, UpgradePrompt, CheckoutButton, useSubscription hook, and accounts API client. AuthStore now parallel-fetches account and subscription data alongside user profile. Also fix folder sidebar not refreshing after tree deletion by dispatching the folder-changed event in handleDeleteTree. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
494
frontend/src/pages/AccountSettingsPage.tsx
Normal file
494
frontend/src/pages/AccountSettingsPage.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X } from 'lucide-react'
|
||||
import { accountsApi } from '@/api'
|
||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useSubscription } from '@/hooks/useSubscription'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { CheckoutButton } from '@/components/subscription/CheckoutButton'
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const { isAccountOwner } = usePermissions()
|
||||
const { plan, limits, usage } = useSubscription()
|
||||
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)
|
||||
|
||||
// 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 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">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sub = subscription?.subscription
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-8 w-8 text-primary" />
|
||||
<h1 className="text-2xl font-bold 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="rounded-lg border border-border bg-card p-4 shadow-sm sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-card-foreground">Account Information</h2>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Account Name */}
|
||||
<div>
|
||||
<label className="block font-label text-sm font-medium text-card-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-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
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-primary p-2 text-primary-foreground',
|
||||
'hover:bg-primary/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-input 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-primary hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display Code */}
|
||||
<div>
|
||||
<label className="block font-label text-sm font-medium text-card-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="rounded-lg border border-border bg-card p-4 shadow-sm sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-card-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-secondary text-secondary-foreground',
|
||||
plan === 'pro' && 'bg-primary/10 text-primary',
|
||||
plan === 'team' && 'bg-primary/20 text-primary'
|
||||
)}
|
||||
>
|
||||
<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-green-600',
|
||||
sub.status === 'trialing' && 'bg-blue-500/10 text-blue-600',
|
||||
sub.status === 'past_due' && 'bg-yellow-500/10 text-yellow-600',
|
||||
sub.status === 'canceled' && 'bg-destructive/10 text-destructive',
|
||||
sub.status === 'orphaned' && 'bg-muted 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="rounded-lg border border-border bg-card p-4 shadow-sm sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-card-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">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
member.account_role === 'owner' && 'bg-primary/10 text-primary',
|
||||
member.account_role === 'engineer' && 'bg-secondary text-secondary-foreground',
|
||||
member.account_role === 'viewer' && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{member.account_role}
|
||||
</span>
|
||||
{!member.is_active && (
|
||||
<span className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
{member.account_role !== 'owner' && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
title="Remove member"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite Member Section (owners only) */}
|
||||
{isAccountOwner && (
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-card-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-input bg-background px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">Engineer</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isInviting || !inviteEmail.trim()}
|
||||
className={cn(
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/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-destructive">{inviteError}</p>
|
||||
)}
|
||||
{inviteSuccess && (
|
||||
<p className="text-sm text-green-600">{inviteSuccess}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Pending Invites */}
|
||||
{invites.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-card-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">
|
||||
Expires {new Date(invite.expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs text-secondary-foreground">
|
||||
{invite.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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-background p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-lg font-semibold',
|
||||
isAtLimit ? 'text-destructive' : isNearLimit ? 'text-yellow-600' : '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-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
isAtLimit ? 'bg-destructive' : isNearLimit ? 'bg-yellow-500' : 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSettingsPage
|
||||
@@ -102,7 +102,7 @@ export function TreeEditorPage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const tree = await treesApi.get(id)
|
||||
if (!canEditTree({ author_id: tree.author_id, team_id: tree.team_id })) {
|
||||
if (!canEditTree({ author_id: tree.author_id, account_id: tree.account_id })) {
|
||||
navigate('/trees')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ export function TreeLibraryPage() {
|
||||
try {
|
||||
await treesApi.delete(treeToDelete.id)
|
||||
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
|
||||
window.dispatchEvent(new Event('folder-changed'))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tree:', err)
|
||||
setError('Failed to delete tree')
|
||||
@@ -352,7 +353,7 @@ export function TreeLibraryPage() {
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddToFolderMenu treeId={tree.id} onFolderCreated={handleCreateFolder} />
|
||||
{canEditTree({ author_id: tree.author_id, team_id: tree.team_id }) && (
|
||||
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
|
||||
<Link
|
||||
to={`/trees/${tree.id}/edit`}
|
||||
className={cn(
|
||||
|
||||
@@ -6,3 +6,4 @@ export { default as TreeEditorPage } from './TreeEditorPage'
|
||||
export { default as SessionHistoryPage } from './SessionHistoryPage'
|
||||
export { default as SessionDetailPage } from './SessionDetailPage'
|
||||
export { default as SettingsPage } from './SettingsPage'
|
||||
export { default as AccountSettingsPage } from './AccountSettingsPage'
|
||||
|
||||
Reference in New Issue
Block a user