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:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
48
frontend/src/api/accounts.ts
Normal file
48
frontend/src/api/accounts.ts
Normal 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
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
32
frontend/src/components/common/UpgradePrompt.tsx
Normal file
32
frontend/src/components/common/UpgradePrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
24
frontend/src/components/subscription/CheckoutButton.tsx
Normal file
24
frontend/src/components/subscription/CheckoutButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
45
frontend/src/hooks/useSubscription.ts
Normal file
45
frontend/src/hooks/useSubscription.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
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'
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
62
frontend/src/types/account.ts
Normal file
62
frontend/src/types/account.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface StepCategory {
|
||||
name: string
|
||||
description?: string
|
||||
display_order: number
|
||||
team_id?: string
|
||||
account_id?: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user