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:
chihlasm
2026-02-07 02:39:15 -05:00
parent e0089a9c5a
commit 7a6f839ef4
26 changed files with 786 additions and 38 deletions

View File

@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@stripe/stripe-js": "^8.7.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -1430,6 +1431,15 @@
"win32"
]
},
"node_modules/@stripe/stripe-js": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz",
"integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@stripe/stripe-js": "^8.7.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -0,0 +1,48 @@
import apiClient from './client'
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types'
export const accountsApi = {
async getMyAccount(): Promise<Account> {
const response = await apiClient.get<Account>('/accounts/me')
return response.data
},
async getMySubscription(): Promise<SubscriptionDetails> {
const response = await apiClient.get<SubscriptionDetails>('/accounts/me/subscription')
return response.data
},
async updateMyAccount(data: { name?: string }): Promise<Account> {
const response = await apiClient.patch<Account>('/accounts/me', data)
return response.data
},
async getMembers(): Promise<AccountMember[]> {
const response = await apiClient.get<AccountMember[]>('/accounts/me/members')
return response.data
},
async updateMemberRole(userId: string, role: string): Promise<AccountMember> {
const response = await apiClient.patch<AccountMember>(
`/accounts/me/members/${userId}/role`,
{ role }
)
return response.data
},
async removeMember(userId: string): Promise<void> {
await apiClient.delete(`/accounts/me/members/${userId}`)
},
async createInvite(data: { email: string; role: string }): Promise<AccountInvite> {
const response = await apiClient.post<AccountInvite>('/accounts/me/invites', data)
return response.data
},
async getInvites(): Promise<AccountInvite[]> {
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data
},
}
export default accountsApi

View File

@@ -2,9 +2,9 @@ import apiClient from './client'
import type { Category, CategoryListItem, CategoryCreate, CategoryUpdate } from '@/types'
export const categoriesApi = {
async list(includeInactive = false, teamOnly = false): Promise<CategoryListItem[]> {
async list(includeInactive = false, accountOnly = false): Promise<CategoryListItem[]> {
const response = await apiClient.get<CategoryListItem[]>('/categories', {
params: { include_inactive: includeInactive, team_only: teamOnly },
params: { include_inactive: includeInactive, account_only: accountOnly },
})
return response.data
},

View File

@@ -8,3 +8,4 @@ export { default as categoriesApi } from './categories'
export { default as foldersApi } from './folders'
export { default as stepsApi } from './steps'
export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts'

View File

@@ -2,16 +2,16 @@ import apiClient from './client'
import type { Tag, TagListItem, TagCreate, TagAssignment } from '@/types'
export const tagsApi = {
async list(includeTeam = true): Promise<TagListItem[]> {
async list(includeAccount = true): Promise<TagListItem[]> {
const response = await apiClient.get<TagListItem[]>('/tags', {
params: { include_team: includeTeam },
params: { include_account: includeAccount },
})
return response.data
},
async search(query: string, limit = 10, includeTeam = true): Promise<TagListItem[]> {
async search(query: string, limit = 10, includeAccount = true): Promise<TagListItem[]> {
const response = await apiClient.get<TagListItem[]>('/tags/search', {
params: { q: query, limit, include_team: includeTeam },
params: { q: query, limit, include_account: includeAccount },
})
return response.data
},

View File

@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils'
import { useSubscription } from '@/hooks/useSubscription'
interface UpgradePromptProps {
feature: string // e.g., "create more trees", "start more sessions"
className?: string
}
export function UpgradePrompt({ feature, className }: UpgradePromptProps) {
const { plan } = useSubscription()
return (
<div className={cn(
'rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4',
className
)}>
<h3 className="font-semibold text-foreground">Plan Limit Reached</h3>
<p className="mt-1 text-sm text-muted-foreground">
Your {plan} plan doesn't allow you to {feature}. Upgrade your plan to continue.
</p>
<button
className={cn(
'mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
onClick={() => window.location.href = '/account'}
>
View Plans
</button>
</div>
)
}

View File

@@ -49,6 +49,7 @@ export function AppLayout() {
const navItems = [
{ path: '/trees', label: 'Trees' },
{ path: '/sessions', label: 'Sessions' },
{ path: '/account', label: 'Account' },
{ path: '/settings', label: 'Settings' },
]
@@ -98,12 +99,12 @@ export function AppLayout() {
className={cn(
'hidden rounded-full px-2 py-0.5 text-xs font-medium sm:inline-block',
effectiveRole === 'super_admin' && 'bg-red-500/10 text-red-600 dark:text-red-400',
effectiveRole === 'team_admin' && 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
effectiveRole === 'owner' && 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
effectiveRole === 'viewer' && 'bg-gray-500/10 text-gray-600 dark:text-gray-400'
)}
>
{effectiveRole === 'super_admin' ? 'Super Admin' :
effectiveRole === 'team_admin' ? 'Team Admin' :
effectiveRole === 'owner' ? 'Owner' :
'Viewer'}
</span>
)}
@@ -158,12 +159,12 @@ export function AppLayout() {
className={cn(
'mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium',
effectiveRole === 'super_admin' && 'bg-red-500/10 text-red-600 dark:text-red-400',
effectiveRole === 'team_admin' && 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
effectiveRole === 'owner' && 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
effectiveRole === 'viewer' && 'bg-gray-500/10 text-gray-600 dark:text-gray-400'
)}
>
{effectiveRole === 'super_admin' ? 'Super Admin' :
effectiveRole === 'team_admin' ? 'Team Admin' :
effectiveRole === 'owner' ? 'Owner' :
'Viewer'}
</span>
)}

View File

@@ -27,7 +27,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
if (requiredRole) {
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 4,
team_admin: 3,
owner: 3,
engineer: 2,
viewer: 1,
}

View File

@@ -0,0 +1,24 @@
import { cn } from '@/lib/utils'
interface CheckoutButtonProps {
plan: 'pro' | 'team'
className?: string
}
export function CheckoutButton({ plan, className }: CheckoutButtonProps) {
const planLabels = { pro: 'Pro', team: 'Team' }
return (
<button
disabled
title="Billing coming soon"
className={cn(
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'disabled:opacity-50 disabled:cursor-not-allowed',
className
)}
>
Upgrade to {planLabels[plan]} (Coming Soon)
</button>
)
}

View File

@@ -119,7 +119,7 @@ export function TreeMetadataForm() {
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
{cat.team_id ? ' (Team)' : ''}
{cat.account_id ? ' (Account)' : ''}
</option>
))}
<option value="__custom__">+ Add custom category</option>

View File

@@ -1,18 +1,18 @@
/**
* Centralized permissions hook for ResolutionFlow.
*
* Role hierarchy: super_admin > team_admin > engineer > viewer
* Role hierarchy: super_admin > owner > engineer > viewer
*
* Mirrors backend logic in backend/app/core/permissions.py
*/
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
export type EffectiveRole = 'super_admin' | 'team_admin' | 'engineer' | 'viewer'
export type EffectiveRole = 'super_admin' | 'owner' | 'engineer' | 'viewer'
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 4,
team_admin: 3,
owner: 3,
engineer: 2,
viewer: 1,
}
@@ -20,7 +20,7 @@ const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
function getEffectiveRole(user: User | null): EffectiveRole {
if (!user) return 'viewer'
if (user.is_super_admin) return 'super_admin'
if (user.is_team_admin && user.team_id) return 'team_admin'
if (user.account_role === 'owner') return 'owner'
return user.role as EffectiveRole
}
@@ -37,7 +37,7 @@ export function usePermissions() {
return {
effectiveRole,
isSuperAdmin: effectiveRole === 'super_admin',
isTeamAdmin: effectiveRole === 'team_admin' || effectiveRole === 'super_admin',
isAccountOwner: effectiveRole === 'owner' || effectiveRole === 'super_admin',
isEngineer: hasMinimumRole(user, 'engineer'),
isViewer: effectiveRole === 'viewer',
@@ -46,12 +46,12 @@ export function usePermissions() {
canCreateSteps: hasMinimumRole(user, 'engineer'),
// Resource-specific checks
canEditTree: (tree: { author_id: string | null; team_id?: string | null }) => {
canEditTree: (tree: { author_id: string | null; account_id?: string | null }) => {
if (!user) return false
if (user.is_super_admin) return true
if (!hasMinimumRole(user, 'engineer')) return false
if (tree.author_id && tree.author_id === user.id) return true
if (user.is_team_admin && tree.team_id === user.team_id && user.team_id) return true
if (user.account_role === 'owner' && tree.account_id === user.account_id && user.account_id) return true
return false
},
@@ -68,8 +68,8 @@ export function usePermissions() {
},
// Management permissions
canManageCategories: hasMinimumRole(user, 'team_admin'),
canManageCategories: hasMinimumRole(user, 'owner'),
canManageGlobalCategories: effectiveRole === 'super_admin',
canManageTeam: effectiveRole === 'super_admin' || (effectiveRole === 'team_admin'),
canManageAccount: effectiveRole === 'super_admin' || effectiveRole === 'owner',
}
}

View File

@@ -0,0 +1,45 @@
import { useAuthStore } from '@/store/authStore'
export function useSubscription() {
const subscription = useAuthStore((s) => s.subscription)
const plan = subscription?.subscription.plan ?? 'free'
const limits = subscription?.limits ?? null
const usage = subscription?.usage ?? null
const isActive = subscription?.subscription.status === 'active' || subscription?.subscription.status === 'trialing'
const isPaidPlan = plan === 'pro' || plan === 'team'
const canUseFeature = (feature: 'custom_branding' | 'priority_support'): boolean => {
if (!limits) return false
return limits[feature]
}
const isAtTreeLimit = (): boolean => {
if (!limits || !usage) return false
if (limits.max_trees === null) return false // unlimited
return usage.tree_count >= limits.max_trees
}
const isAtSessionLimit = (): boolean => {
if (!limits || !usage) return false
if (limits.max_sessions_per_month === null) return false
return usage.session_count_this_month >= limits.max_sessions_per_month
}
const formatLimit = (value: number | null): string => {
return value === null ? 'Unlimited' : String(value)
}
return {
plan,
limits,
usage,
isActive,
isPaidPlan,
canUseFeature,
isAtTreeLimit,
isAtSessionLimit,
formatLimit,
}
}

View 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

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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'

View File

@@ -10,6 +10,7 @@ import {
SessionHistoryPage,
SessionDetailPage,
SettingsPage,
AccountSettingsPage,
} from '@/pages'
export const router = createBrowserRouter([
@@ -64,6 +65,10 @@ export const router = createBrowserRouter([
path: 'settings',
element: <SettingsPage />,
},
{
path: 'account',
element: <AccountSettingsPage />,
},
],
},
])

View File

@@ -1,11 +1,14 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User, Token, UserCreate, UserLogin } from '@/types'
import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types'
import { authApi } from '@/api'
import { apiClient } from '@/api'
interface AuthState {
user: User | null
token: Token | null
account: Account | null
subscription: SubscriptionDetails | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
@@ -25,6 +28,8 @@ export const useAuthStore = create<AuthState>()(
(set, get) => ({
user: null,
token: null,
account: null,
subscription: null,
isAuthenticated: false,
isLoading: false,
error: null,
@@ -70,15 +75,30 @@ export const useAuthStore = create<AuthState>()(
} finally {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({ user: null, token: null, isAuthenticated: false, error: null })
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
}
},
fetchUser: async () => {
set({ isLoading: true })
try {
const user = await authApi.me()
set({ user, isLoading: false })
const [userResult, accountResult, subscriptionResult] = await Promise.allSettled([
authApi.me(),
apiClient.get<Account>('/accounts/me').then(r => r.data),
apiClient.get<SubscriptionDetails>('/accounts/me/subscription').then(r => r.data),
])
const user = userResult.status === 'fulfilled' ? userResult.value : null
const account = accountResult.status === 'fulfilled' ? accountResult.value : null
const subscription = subscriptionResult.status === 'fulfilled' ? subscriptionResult.value : null
if (!user) {
// User fetch failed — propagate the error
const reason = userResult.status === 'rejected' ? userResult.reason : new Error('Failed to fetch user')
throw reason
}
set({ user, account, subscription, isLoading: false })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to fetch user'
set({ error: message, isLoading: false })
@@ -95,6 +115,8 @@ export const useAuthStore = create<AuthState>()(
partialize: (state) => ({
token: state.token,
isAuthenticated: state.isAuthenticated,
account: state.account,
subscription: state.subscription,
}),
}
)

View File

@@ -0,0 +1,62 @@
export interface Account {
id: string
name: string
display_code: string
owner_id: string
created_at: string
updated_at: string
}
export interface Subscription {
id: string
account_id: string
plan: 'free' | 'pro' | 'team'
status: 'active' | 'past_due' | 'canceled' | 'trialing' | 'orphaned'
current_period_start: string | null
current_period_end: string | null
created_at: string
updated_at: string
}
export interface PlanLimits {
plan: string
max_trees: number | null
max_sessions_per_month: number | null
max_users: number | null
custom_branding: boolean
priority_support: boolean
export_formats: string[]
}
export interface SubscriptionDetails {
subscription: Subscription
limits: PlanLimits
usage: {
tree_count: number
session_count_this_month: number
user_count: number
}
}
export interface AccountInvite {
id: string
account_id: string
email: string
role: 'engineer' | 'viewer'
code: string
invited_by_id: string
accepted_by_id: string | null
expires_at: string
used_at: string | null
created_at: string
}
export interface AccountMember {
id: string
email: string
name: string
account_role: string
is_active: boolean
created_at: string
last_login: string | null
}

View File

@@ -5,7 +5,7 @@ export interface Category {
name: string
slug: string
description: string | null
team_id: string | null
account_id: string | null
display_order: number
is_active: boolean
created_at: string
@@ -18,7 +18,7 @@ export interface CategoryListItem {
name: string
slug: string
description: string | null
team_id: string | null
account_id: string | null
display_order: number
is_active: boolean
tree_count: number
@@ -27,7 +27,7 @@ export interface CategoryListItem {
export interface CategoryCreate {
name: string
description?: string | null
team_id?: string | null
account_id?: string | null
}
export interface CategoryUpdate {

View File

@@ -7,6 +7,7 @@ export * from './tag'
export * from './category'
export * from './folder'
export * from './step'
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
// API response wrapper types
export interface PaginatedResponse<T> {

View File

@@ -56,7 +56,7 @@ export interface StepCategory {
name: string
description?: string
display_order: number
team_id?: string
account_id?: string
is_active: boolean
}

View File

@@ -4,7 +4,7 @@ export interface Tag {
id: string
name: string
slug: string
team_id: string | null
account_id: string | null
usage_count: number
created_at: string
}
@@ -13,13 +13,13 @@ export interface TagListItem {
id: string
name: string
slug: string
team_id: string | null
account_id: string | null
usage_count: number
}
export interface TagCreate {
name: string
team_id?: string | null
account_id?: string | null
}
export interface TagAssignment {

View File

@@ -67,7 +67,7 @@ export interface Tree {
tags: string[]
tree_structure: TreeStructure
author_id: string | null
team_id: string | null
account_id: string | null
is_active: boolean
is_public: boolean
is_default: boolean
@@ -86,7 +86,7 @@ export interface TreeListItem {
category_info: CategoryInfo | null
tags: string[]
author_id: string | null
team_id: string | null
account_id: string | null
is_active: boolean
is_public: boolean
is_default: boolean

View File

@@ -6,8 +6,8 @@ export interface User {
name: string
role: UserRole
is_super_admin: boolean
is_team_admin: boolean
team_id: string | null
account_id: string
account_role: 'owner' | 'engineer' | 'viewer'
created_at: string
last_login: string | null
}