diff --git a/src/components/ApiKeys.tsx b/src/components/ApiKeys.tsx index 751ae35..01e48d9 100644 --- a/src/components/ApiKeys.tsx +++ b/src/components/ApiKeys.tsx @@ -25,11 +25,22 @@ interface ApiKey { updatedAt: string; } +interface TeamApiKey extends ApiKey { + teamId?: string | null; + user?: { id: string; email: string; fullName: string | null }; +} + const ApiKeys: React.FC = () => { const { user } = useAuth(); const navigate = useNavigate(); const [apiKeys, setApiKeys] = useState([]); + const [teamApiKeys, setTeamApiKeys] = useState([]); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isCreateTeamKeyOpen, setIsCreateTeamKeyOpen] = useState(false); + const [teamKeyName, setTeamKeyName] = useState(''); + const [teamKeyEnvironment, setTeamKeyEnvironment] = useState<'test' | 'live'>( + 'live' + ); const [keyName, setKeyName] = useState(''); const [environment, setEnvironment] = useState<'test' | 'live'>('live'); const [loading, setLoading] = useState(false); @@ -37,6 +48,10 @@ const ApiKeys: React.FC = () => { const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); const [copiedKey, setCopiedKey] = useState(false); + const effectivePlan = user?.effectivePlan ?? user?.plan ?? 'free'; + const isTeamOwnerOrAdmin = + user?.teamRole === 'owner' || user?.teamRole === 'admin'; + // Confirmation Modal State const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; @@ -57,7 +72,9 @@ const ApiKeys: React.FC = () => { // Load API keys on mount useEffect(() => { loadApiKeys(); - }, []); + if (isTeamOwnerOrAdmin) loadTeamApiKeys(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTeamOwnerOrAdmin]); const loadApiKeys = async () => { setLoading(true); @@ -74,6 +91,79 @@ const ApiKeys: React.FC = () => { setLoading(false); }; + const loadTeamApiKeys = async () => { + const response = await apiKeyService.listTeamApiKeys(); + if (response.success && response.apiKeys) { + setTeamApiKeys(response.apiKeys as TeamApiKey[]); + } + }; + + const handleCreateTeamKey = async () => { + if (!teamKeyName.trim()) return; + setLoading(true); + setError(null); + const response = await apiKeyService.createTeamApiKey( + teamKeyName.trim(), + teamKeyEnvironment + ); + if (response.success && response.apiKey) { + setIsCreateTeamKeyOpen(false); + setTeamKeyName(''); + setTeamKeyEnvironment('live'); + setNewlyCreatedKey(response.apiKey.key || null); + loadTeamApiKeys(); // background refresh, no await + } else { + setError(response.error || 'Failed to create team API key'); + } + setLoading(false); + }; + + const handleDeleteTeamKey = (keyId: string) => { + setConfirmModal({ + isOpen: true, + type: 'danger', + title: 'Delete Team API Key', + message: + 'Are you sure you want to delete this team API key? This action cannot be undone.', + confirmText: 'Delete Key', + action: async () => { + setLoading(true); + setError(null); + const response = await apiKeyService.deleteApiKey(keyId); + if (response.success) { + await loadTeamApiKeys(); + } else { + setError(response.error || 'Failed to delete team API key'); + } + setLoading(false); + closeConfirmModal(); + }, + }); + }; + + const handleRevokeTeamKey = (keyId: string) => { + setConfirmModal({ + isOpen: true, + type: 'warning', + title: 'Revoke Team API Key', + message: + 'Are you sure you want to revoke this team API key? All team members using it will immediately lose access.', + confirmText: 'Revoke Key', + action: async () => { + setLoading(true); + setError(null); + const response = await apiKeyService.revokeApiKey(keyId); + if (response.success) { + await loadTeamApiKeys(); + } else { + setError(response.error || 'Failed to revoke team API key'); + } + setLoading(false); + closeConfirmModal(); + }, + }); + }; + const handleCreateKey = async () => { if (!keyName.trim()) { setError('Please enter a key name'); @@ -89,16 +179,11 @@ const ApiKeys: React.FC = () => { ); if (response.success && response.apiKey) { - // Store the newly created key to display it - setNewlyCreatedKey(response.apiKey.key || null); - - // Reload the list - await loadApiKeys(); - - // Close create modal setIsCreateModalOpen(false); setKeyName(''); setEnvironment('live'); + setNewlyCreatedKey(response.apiKey.key || null); + loadApiKeys(); // background refresh, no await } else { setError(response.error || 'Failed to create API key'); } @@ -195,7 +280,7 @@ const ApiKeys: React.FC = () => { }); }, []); - const isPro = user?.plan === 'pro' || user?.plan === 'enterprise'; + const isPro = effectivePlan === 'pro' || effectivePlan === 'enterprise'; if (!isPro) { return ( @@ -384,6 +469,130 @@ session_id = client.create_session(name="My Agent")`} + + {/* Team API Keys — visible to team owners/admins only */} + {isTeamOwnerOrAdmin && ( +
+
+
+

+ Team API Keys ({teamApiKeys.length}) +

+

+ Keys shared across your team. Any member can authenticate + with these. +

+
+ +
+ + {teamApiKeys.length === 0 ? ( +
+ No team keys yet. Create one to share access with your team. +
+ ) : ( + <> + {/* Table Header */} +
+
Key
+
Created
+
Last Used
+
Usage (30d)
+
+
+ + {/* Table Rows */} + {teamApiKeys.map(k => { + const keyUsage = keyUsageMap.get(k.id); + return ( +
+
+
+
+
+ + {k.name} + + {k.revoked && ( + + Revoked + + )} + + {k.environment} + + + Team + +
+ + {maskKey(k.keyPrefix)} + + {k.user && ( +

+ by {k.user.fullName ?? k.user.email} +

+ )} +
+
+
+
+ {formatDate(k.createdAt)} +
+
+ {k.lastUsedAt ? formatDate(k.lastUsedAt) : 'Never'} +
+
+ {keyUsage ? ( +
+

+ {keyUsage.tokens.toLocaleString()} +

+

+ ${keyUsage.cost.toFixed(4)} +

+
+ ) : ( + + )} +
+
+ {!k.revoked && ( + + )} + +
+
+ ); + })} + + )} +
+ )} @@ -472,7 +681,7 @@ session_id = client.create_session(name="My Agent")`} {/* New Key Display Modal */} {newlyCreatedKey && ( -
+

API Key Created @@ -522,6 +731,92 @@ session_id = client.create_session(name="My Agent")`}

)} + {/* Create Team API Key Modal */} + {isCreateTeamKeyOpen && ( +
+
+

+ Create Team API Key +

+

+ This key can be used by any team member in their CLI. +

+ + {/* Key Name Input */} +
+ + setTeamKeyName(e.target.value)} + placeholder="e.g., CI/CD Pipeline" + className="w-full bg-transparent border border-white/[0.08] rounded-xl px-4 py-3 text-white placeholder-neutral-600 focus:outline-none focus:border-white/20 transition-colors" + disabled={loading} + /> +
+ + {/* Environment Selection */} +
+ +
+ + +
+

+ refactron_{teamKeyEnvironment}_ +

+
+ + {/* Action Buttons */} +
+ + +
+
+
+ )} + {/* Confirmation Modal */} { } /> + {/* Protected Team Management Route */} + + + + + + } + /> + {/* Protected Account Settings Route */} { description: 'For growing engineering teams', features: [ 'Everything in Free', - 'Autofix with verification', + 'LLM-powered analysis & autofix', + 'AI refactoring with verification', 'Metrics & maintainability reports', 'CI/CD integration', 'Priority updates', @@ -105,6 +106,7 @@ const Billing: React.FC = () => { description: 'For production & regulated environments', features: [ 'Everything in Pro', + 'Team management & collaboration', 'On-prem / private deployments', 'Advanced verification controls', 'Audit logs & compliance support', diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index ceb02f6..63c10ff 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -24,7 +24,15 @@ import { Settings, Bell, Shield, + Users, LucideIcon, + Trash2, + CheckCheck, + AlertTriangle, + Clock, + X, + Check, + CheckCircle2, } from 'lucide-react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { getApiBaseUrl } from '../utils/urlUtils'; @@ -37,6 +45,7 @@ interface Notification { message: string; read: boolean; createdAt: string; + metadata?: { inviteToken?: string } | null; } interface DashboardLayoutProps { @@ -77,12 +86,90 @@ function toOrgSlug(name?: string | null): string { function formatNotifTime(iso: string): string { const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); + if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; - return `${Math.floor(hrs / 24)}d ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); } +const BILLING_TYPES = new Set([ + 'subscription_created', + 'subscription_cancelled', + 'trial_warning', + 'quota_warning_80', + 'quota_warning_95', + 'quota_exceeded', +]); + +interface NotifMeta { + icon: LucideIcon; + iconClass: string; + bgClass: string; + label: string; +} +const TYPE_META: Record = { + team_invite_received: { + icon: Users, + iconClass: 'text-teal-400', + bgClass: 'bg-teal-400/10', + label: 'Team Invite', + }, + team_invite_sent: { + icon: Users, + iconClass: 'text-neutral-400', + bgClass: 'bg-white/[0.06]', + label: 'Team', + }, + subscription_created: { + icon: CheckCircle2, + iconClass: 'text-emerald-400', + bgClass: 'bg-emerald-400/10', + label: 'Billing', + }, + subscription_cancelled: { + icon: CreditCard, + iconClass: 'text-red-400', + bgClass: 'bg-red-400/10', + label: 'Billing', + }, + trial_warning: { + icon: Clock, + iconClass: 'text-amber-400', + bgClass: 'bg-amber-400/10', + label: 'Trial', + }, + quota_warning_80: { + icon: AlertTriangle, + iconClass: 'text-amber-400', + bgClass: 'bg-amber-400/10', + label: 'Quota', + }, + quota_warning_95: { + icon: AlertTriangle, + iconClass: 'text-orange-400', + bgClass: 'bg-orange-400/10', + label: 'Quota', + }, + quota_exceeded: { + icon: AlertTriangle, + iconClass: 'text-red-400', + bgClass: 'bg-red-400/10', + label: 'Quota', + }, +}; +const DEFAULT_META: NotifMeta = { + icon: Bell, + iconClass: 'text-neutral-500', + bgClass: 'bg-white/[0.06]', + label: 'System', +}; + // ── sub-components ──────────────────────────────────────────────────────────── function Avatar({ @@ -136,7 +223,7 @@ function NavItem({ const SIDEBAR_KEY = 'sidebar-collapsed'; const DashboardLayout: React.FC = ({ children }) => { - const { user, logout } = useAuth(); + const { user, logout, updateUser } = useAuth(); const navigate = useNavigate(); const location = useLocation(); @@ -147,10 +234,19 @@ const DashboardLayout: React.FC = ({ children }) => { const [isProfileDropdownOpen, setIsProfileDropdownOpen] = useState(false); const [isNotifOpen, setIsNotifOpen] = useState(false); const [notifications, setNotifications] = useState([]); + const [notifTotal, setNotifTotal] = useState(0); + const [notifPage, setNotifPage] = useState(1); + const [notifLoadingMore, setNotifLoadingMore] = useState(false); + const [notifFilter, setNotifFilter] = useState< + 'all' | 'unread' | 'team' | 'billing' + >('all'); + // notifId → 'accepting' | 'declining' | 'accepted' | 'declined' | string (error msg) + const [inviteActions, setInviteActions] = useState>( + {} + ); const orgDropdownRef = useRef(null); const profileDropdownRef = useRef(null); - const notifRef = useRef(null); const apiBase = getApiBaseUrl(); const token = localStorage.getItem('accessToken'); @@ -164,29 +260,162 @@ const DashboardLayout: React.FC = ({ children }) => { // ── notifications ──────────────────────────────────────────────────────── - const fetchNotifications = useCallback(async () => { - try { - const res = await fetch(`${apiBase}/api/notifications`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - const data = await res.json(); - setNotifications(data.notifications ?? []); - } - } catch {} - }, [apiBase, token]); + const fetchNotifications = useCallback( + async (page = 1, append = false) => { + try { + const res = await fetch( + `${apiBase}/api/notifications?page=${page}&limit=20`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + if (res.ok) { + const data = await res.json(); + if (append) { + setNotifications(prev => [...prev, ...(data.notifications ?? [])]); + } else { + setNotifications(data.notifications ?? []); + } + setNotifTotal(data.total ?? 0); + } + } catch {} + }, + [apiBase, token] + ); useEffect(() => { fetchNotifications(); - const interval = setInterval(fetchNotifications, 60000); + const interval = setInterval(() => fetchNotifications(), 60000); return () => clearInterval(interval); }, [fetchNotifications]); const unreadCount = notifications.filter(n => !n.read).length; + const handleAcceptInvite = useCallback( + async (notifId: string, inviteToken: string) => { + setInviteActions(prev => ({ ...prev, [notifId]: 'accepting' })); + try { + const res = await fetch(`${apiBase}/api/team/invites/accept`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ token: inviteToken }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed to accept invite'); + // Clear inviteToken so buttons disappear without removing the notification message + setNotifications(prev => + prev.map(n => (n.id === notifId ? { ...n, metadata: null } : n)) + ); + setInviteActions(prev => ({ ...prev, [notifId]: 'accepted' })); + // Refresh auth so sidebar shows Team nav immediately + const meRes = await fetch(`${apiBase}/api/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (meRes.ok) { + const meData = await meRes.json(); + if (meData.user) updateUser(meData.user); + } + } catch (err) { + const msg = + err instanceof Error ? err.message : 'Failed to accept invite'; + setInviteActions(prev => ({ ...prev, [notifId]: msg })); + setTimeout( + () => setInviteActions(prev => ({ ...prev, [notifId]: '' })), + 4000 + ); + } + }, + [apiBase, token, updateUser] + ); + + const handleDeclineInvite = useCallback( + async (notifId: string, inviteToken: string) => { + setInviteActions(prev => ({ ...prev, [notifId]: 'declining' })); + try { + const res = await fetch(`${apiBase}/api/team/invites/decline`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ token: inviteToken }), + }); + const data = await res.json(); + if (!res.ok) + throw new Error(data.message ?? 'Failed to decline invite'); + // Clear inviteToken so buttons disappear without removing the notification message + setNotifications(prev => + prev.map(n => (n.id === notifId ? { ...n, metadata: null } : n)) + ); + } catch (err) { + const msg = + err instanceof Error ? err.message : 'Failed to decline invite'; + setInviteActions(prev => ({ ...prev, [notifId]: msg })); + setTimeout( + () => setInviteActions(prev => ({ ...prev, [notifId]: '' })), + 4000 + ); + } + }, + [apiBase, token] + ); + + const handleMarkRead = useCallback( + async (notifId: string) => { + setNotifications(prev => + prev.map(n => (n.id === notifId ? { ...n, read: true } : n)) + ); + try { + await fetch(`${apiBase}/api/notifications/${notifId}/read`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch {} + }, + [apiBase, token] + ); + + const handleDeleteNotification = useCallback( + async (notifId: string) => { + setNotifications(prev => prev.filter(n => n.id !== notifId)); + setNotifTotal(prev => Math.max(0, prev - 1)); + try { + await fetch(`${apiBase}/api/notifications/${notifId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch {} + }, + [apiBase, token] + ); + + const handleClearAll = useCallback(async () => { + setNotifications([]); + setNotifTotal(0); + try { + await fetch(`${apiBase}/api/notifications`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch {} + }, [apiBase, token]); + + const handleLoadMore = useCallback(async () => { + const nextPage = notifPage + 1; + setNotifLoadingMore(true); + await fetchNotifications(nextPage, true); + setNotifPage(nextPage); + setNotifLoadingMore(false); + }, [notifPage, fetchNotifications]); + const handleOpenNotif = async () => { - setIsNotifOpen(v => !v); - if (!isNotifOpen && notifications.some(n => !n.read)) { + const opening = !isNotifOpen; + setIsNotifOpen(opening); + setNotifFilter('all'); + if (opening && notifications.some(n => !n.read)) { try { await fetch(`${apiBase}/api/notifications/read-all`, { method: 'POST', @@ -205,7 +434,6 @@ const DashboardLayout: React.FC = ({ children }) => { setIsOrgDropdownOpen(false); if (!profileDropdownRef.current?.contains(e.target as Node)) setIsProfileDropdownOpen(false); - if (!notifRef.current?.contains(e.target as Node)) setIsNotifOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); @@ -229,9 +457,12 @@ const DashboardLayout: React.FC = ({ children }) => { const accountItems: NavItemDef[] = [ { icon: CreditCard, label: 'Billing', path: '/settings/billing' }, { icon: Settings, label: 'Account', path: '/settings/account' }, - ...(user?.plan === 'enterprise' + ...(user?.effectivePlan === 'enterprise' || user?.plan === 'enterprise' ? [{ icon: Shield, label: 'Audit Logs', path: '/settings/audit-logs' }] : []), + ...(user?.plan === 'enterprise' || user?.teamRole != null + ? [{ icon: Users, label: 'Team', path: '/settings/team' }] + : []), ]; // ── github connect ──────────────────────────────────────────────────────── @@ -253,8 +484,9 @@ const DashboardLayout: React.FC = ({ children }) => { const badge = planBadgeLabel(user?.plan); const formattedOrgName = formatOrgName(user?.organizationName); - const dropdownPopover = - 'absolute bottom-full left-0 right-0 mb-2 bg-[#0d0d0d] border border-white/[0.08] rounded-xl shadow-2xl z-50 overflow-hidden'; + const dropdownPopover = collapsed + ? 'absolute bottom-0 left-full ml-2 w-60 bg-[#0d0d0d] border border-white/[0.08] rounded-xl shadow-2xl z-50 overflow-hidden' + : 'absolute bottom-full left-0 right-0 mb-2 bg-[#0d0d0d] border border-white/[0.08] rounded-xl shadow-2xl z-50 overflow-hidden'; const popoverMotion = { initial: { opacity: 0, y: 10, scale: 0.95 }, animate: { opacity: 1, y: 0, scale: 1 }, @@ -270,7 +502,8 @@ const DashboardLayout: React.FC = ({ children }) => { {/* Logo */}
@@ -400,76 +633,35 @@ const DashboardLayout: React.FC = ({ children }) => { {/* Bottom actions */}
{/* Notifications */} -
- - - - {isNotifOpen && ( - -
-

- Notifications -

-
- {notifications.length === 0 ? ( -

- No notifications yet. -

- ) : ( -
- {notifications.slice(0, 10).map(n => ( -
-

- {n.message} -

-

- {formatNotifTime(n.createdAt)} -

-
- ))} -
- )} -
+ {!collapsed && ( + Notifications )} -
-
+
+ {!collapsed && unreadCount > 0 && ( + + {unreadCount} new + + )} + {/* GitHub */} + )} + {notifications.length > 0 && ( + + )} + +
+
+ + {/* Filter tabs */} +
+ {(['all', 'unread', 'team', 'billing'] as const).map(f => { + const count = + f === 'all' + ? notifications.length + : f === 'unread' + ? notifications.filter(n => !n.read).length + : f === 'team' + ? notifications.filter(n => + n.type.startsWith('team_') + ).length + : notifications.filter(n => + BILLING_TYPES.has(n.type) + ).length; + return ( + + ); + })} +
+ + {/* Notification list */} +
+ {filteredNotifs.length === 0 ? ( +
+ +

+ {notifFilter === 'unread' + ? "You're all caught up" + : notifFilter === 'team' + ? 'No team notifications' + : notifFilter === 'billing' + ? 'No billing notifications' + : 'No notifications yet'} +

+
+ ) : ( +
+ {groupedNotifs.map(group => ( +
+

+ {group.label} +

+ {group.items.map(n => { + const meta = TYPE_META[n.type] ?? DEFAULT_META; + const IconComp = meta.icon; + const inviteToken = n.metadata?.inviteToken; + const isInvite = + n.type === 'team_invite_received' && + !!inviteToken; + const actionState = inviteActions[n.id] ?? ''; + const isActing = + actionState === 'accepting' || + actionState === 'declining'; + const isDone = + actionState === 'accepted' || + actionState === 'declined'; + const isError = + !!actionState && !isActing && !isDone; + + return ( +
+ {/* Unread dot */} +
+
+
+ + {/* Type icon */} +
+ +
+ + {/* Content */} +
+
+

+ {n.message} +

+ {/* Per-item actions — visible on hover */} +
+ {!n.read && ( + + )} + +
+
+ +
+ + {formatNotifTime(n.createdAt)} + + + · + + + {meta.label} + +
+ + {/* Invite actions */} + {isInvite && !isDone && ( +
+ {isError ? ( +

+ {actionState} +

+ ) : ( + <> + + + + )} +
+ )} + + {isDone && isInvite && ( +

+ {actionState === 'accepted' + ? '✓ Joined the team' + : 'Invite declined'} +

+ )} +
+
+ ); + })} + + {/* Load more */} + {notifications.length < notifTotal && ( +
+ +
+ )} +
+ ))} +
+ )} +
+ + + ); + })()} +
); }; diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index 1e996c9..6f2ebe8 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -35,7 +35,8 @@ const Onboarding: React.FC = () => { description: 'For growing engineering teams', features: [ 'Everything in Free', - 'Autofix with verification', + 'LLM-powered analysis & autofix', + 'AI refactoring with verification', 'Metrics & maintainability reports', 'CI/CD integration', 'Priority updates', @@ -49,6 +50,7 @@ const Onboarding: React.FC = () => { description: 'For production & regulated environments', features: [ 'Everything in Pro', + 'Team management & collaboration', 'On-prem / private deployments', 'Advanced verification controls', 'Audit logs & compliance support', diff --git a/src/components/PricingSection.tsx b/src/components/PricingSection.tsx index 7c55ecc..568bd14 100644 --- a/src/components/PricingSection.tsx +++ b/src/components/PricingSection.tsx @@ -29,7 +29,8 @@ const PricingSection = () => { trial: '14-Day Free Trial', features: [ 'Everything in Free', - 'Autofix with verification', + 'LLM-powered analysis & autofix', + 'AI refactoring with verification', 'Metrics & maintainability reports', 'CI/CD integration', 'Priority updates', @@ -44,6 +45,7 @@ const PricingSection = () => { description: 'For production & regulated environments', features: [ 'Everything in Pro', + 'Team management & collaboration', 'On-prem / private deployments', 'Advanced verification controls', 'Audit logs & compliance support', diff --git a/src/components/TeamManagement.tsx b/src/components/TeamManagement.tsx new file mode 100644 index 0000000..015b32a --- /dev/null +++ b/src/components/TeamManagement.tsx @@ -0,0 +1,1674 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { + Users, + Lock, + Mail, + Activity, + ChevronLeft, + ChevronRight, + X, + Crown, + Shield, + UserMinus, + Plus, + Check, + AlertCircle, + RefreshCw, +} from 'lucide-react'; +import { useAuth } from '../hooks/useAuth'; +import DashboardLayout from './DashboardLayout'; +import { getApiBaseUrl } from '../utils/urlUtils'; +import { useLocation } from 'react-router-dom'; + +// ── types ───────────────────────────────────────────────────────────────────── + +interface TeamMember { + id: string; + userId: string; + role: string; + joinedAt: string; + user: { id: string; email: string; fullName: string | null }; +} + +interface TeamInvite { + id: string; + email: string; + role: string; + expiresAt: string; + createdAt: string; + invitedBy: { email: string; fullName: string | null }; +} + +interface ActivityLog { + id: string; + action: string; + details: string | null; + ipAddress: string | null; + createdAt: string; + user: { email: string; fullName: string | null }; +} + +interface UsageMember { + userId: string; + email: string; + fullName: string | null; + totalTokens: number; + estimatedCost: number; + requestCount: number; +} + +interface UsageModel { + model: string; + totalTokens: number; + estimatedCost: number; + requestCount: number; +} + +interface UsageOperation { + operationType: string; + count: number; +} + +interface MemberUsageDetail { + totalTokens: number; + totalCost: number; + requestCount: number; + byModel: UsageModel[]; + byOperation: UsageOperation[]; +} + +interface TeamUsage { + totalTokens: number; + totalCost: number; + byMember: UsageMember[]; + byModel: UsageModel[]; + byOperation: UsageOperation[]; +} + +interface HeatmapDay { + date: string; + count: number; +} + +interface TeamData { + id: string; + name: string; + ownerId: string; +} + +type Tab = 'members' | 'invites' | 'activity'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +const actionLabel: Record = { + login: { label: 'Login', color: 'text-neutral-300' }, + logout: { label: 'Logout', color: 'text-neutral-500' }, + api_key_created: { label: 'API Key Created', color: 'text-neutral-300' }, + api_key_revoked: { label: 'API Key Revoked', color: 'text-red-400' }, + password_changed: { label: 'Password Changed', color: 'text-neutral-300' }, + profile_updated: { label: 'Profile Updated', color: 'text-neutral-400' }, + team_invite_sent: { label: 'Invite Sent', color: 'text-neutral-300' }, + team_invite_accepted: { label: 'Invite Accepted', color: 'text-emerald-400' }, + team_invite_cancelled: { + label: 'Invite Cancelled', + color: 'text-neutral-500', + }, + team_member_removed: { label: 'Member Removed', color: 'text-red-400' }, + team_role_changed: { label: 'Role Changed', color: 'text-neutral-300' }, +}; + +function formatDate(iso: string) { + const d = new Date(iso); + return ( + d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + + ' · ' + + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) + ); +} + +function formatRelative(iso: string) { + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); +} + +function RoleBadge({ role }: { role: string }) { + const styles: Record = { + owner: 'bg-white/[0.08] text-white border-white/[0.12]', + admin: 'bg-white/[0.04] text-neutral-300 border-white/[0.08]', + member: 'bg-transparent text-neutral-500 border-white/[0.05]', + }; + const icons: Record = { + owner: , + admin: , + member: null, + }; + return ( + + {icons[role]} + {role} + + ); +} + +function EnterpriseLock() { + return ( +
+
+
+ +
+
+

+ Team Management is available on the Enterprise plan +

+

+ Invite teammates, assign roles, and track team-wide activity from one + place. +

+ + Contact us to upgrade + +
+ ); +} + +function SkeletonRows({ n = 4 }: { n?: number }) { + return ( +
+ {/* Invite button skeleton */} +
+ {/* Table skeleton */} +
+ {/* Header — matches grid-cols-[1fr_auto_auto_auto] */} +
+
+
+
+
+
+ {/* Data rows */} + {Array.from({ length: n }).map((_, i) => ( +
+ {/* Member col: avatar + name/email */} +
+
+
+
+
+
+
+ {/* Role badge */} +
+ {/* Joined */} +
+ {/* Actions */} +
+
+ ))} +
+
+ ); +} + +// ── main component ───────────────────────────────────────────────────────────── + +const TeamManagement: React.FC = () => { + const { user, updateUser } = useAuth(); + const location = useLocation(); + const apiBase = getApiBaseUrl(); + const token = localStorage.getItem('accessToken'); + const effectivePlan = user?.effectivePlan ?? user?.plan ?? 'free'; + const isEnterprise = + effectivePlan === 'enterprise' || user?.plan === 'enterprise'; + + const [tab, setTab] = useState('members'); + const [teamData, setTeamData] = useState(null); + const [members, setMembers] = useState([]); + const [invites, setInvites] = useState([]); + const [activity, setActivity] = useState([]); + const [activityTotal, setActivityTotal] = useState(0); + const [activityPage, setActivityPage] = useState(1); + const [activityTotalPages, setActivityTotalPages] = useState(1); + const [usage, setUsage] = useState(null); + const [usageTab, setUsageTab] = useState<'all' | string>('all'); + const [memberUsageCache, setMemberUsageCache] = useState< + Record + >({}); + const [memberUsageLoading, setMemberUsageLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activityLoaded, setActivityLoaded] = useState(false); + const [activityLoading, setActivityLoading] = useState(false); + const [hasTeamAccess, setHasTeamAccess] = useState(false); + const [heatmap, setHeatmap] = useState([]); + const [heatmapLoaded, setHeatmapLoaded] = useState(false); + const [heatmapError, setHeatmapError] = useState(false); + + // Invite accept state + const inviteToken = new URLSearchParams(location.search).get('invite'); + const [inviteAccepting, setInviteAccepting] = useState(!!inviteToken); + const [inviteResult, setInviteResult] = useState<{ + ok: boolean; + message: string; + } | null>(null); + + // Invite form + const [showInviteForm, setShowInviteForm] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState<'member' | 'admin'>('member'); + const [inviteLoading, setInviteLoading] = useState(false); + const [inviteMsg, setInviteMsg] = useState<{ + type: 'ok' | 'err'; + text: string; + } | null>(null); + + // Per-member action states + const [removingId, setRemovingId] = useState(null); + const [confirmRemoveId, setConfirmRemoveId] = useState(null); + const [cancellingId, setCancellingId] = useState(null); + const [roleChangingId, setRoleChangingId] = useState(null); + const [actionError, setActionError] = useState(null); + const [activityError, setActivityError] = useState(null); + const actionErrorTimer = React.useRef | null>( + null + ); + + const headers = { Authorization: `Bearer ${token}` }; + + // ── data loading ──────────────────────────────────────────────────────────── + + const loadDashboard = useCallback(async () => { + setLoading(true); + setError(null); + try { + // Enterprise owners: ensure team exists on first load only + if (isEnterprise && !teamData) { + const createRes = await fetch(`${apiBase}/api/team`, { + method: 'POST', + headers, + }); + if (!createRes.ok) { + setError('Failed to initialize your team. Please try again.'); + return; + } + } + + // Single combined call for all dashboard data + const res = await fetch(`${apiBase}/api/team/dashboard`, { headers }); + const data = await res.json(); + + if (!data.success) { + setHasTeamAccess(false); + return; + } + + setHasTeamAccess(true); + setTeamData(data.team); + setMembers(data.members ?? []); + setInvites(data.pendingInvites ?? []); + setUsage(data.usage ?? null); + // Reset activity so it re-fetches from page 1 on next tab visit + setActivityPage(1); + setActivityLoaded(false); + setHeatmapLoaded(false); + } catch { + setError( + 'Could not connect to the server. Check your connection and try again.' + ); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEnterprise, apiBase, token]); + + const loadActivity = useCallback( + async (page: number) => { + setActivityLoading(true); + setActivityError(null); + try { + const t = localStorage.getItem('accessToken'); + const res = await fetch(`${apiBase}/api/team/activity?page=${page}`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed to load activity'); + if (data.success) { + setActivity(data.logs); + setActivityTotal(data.total); + setActivityTotalPages(Math.ceil(data.total / 20) || 1); + } + } catch (err: any) { + setActivityError(err.message ?? 'Failed to load activity'); + } finally { + setActivityLoading(false); + } + }, + [apiBase] + ); + + useEffect(() => { + loadDashboard(); + }, [loadDashboard]); + + const loadHeatmap = useCallback(async () => { + setHeatmapError(false); + try { + const t = localStorage.getItem('accessToken'); + const res = await fetch(`${apiBase}/api/team/heatmap?days=30`, { + headers: { Authorization: `Bearer ${t}` }, + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setHeatmapError(true); + return; + } + setHeatmap(data.heatmap ?? []); + } catch { + setHeatmapError(true); + } + }, [apiBase]); + + // Lazy-load activity on first tab visit + useEffect(() => { + if (tab === 'activity' && !activityLoaded && hasTeamAccess) { + loadActivity(1); + setActivityLoaded(true); + } + if (tab === 'activity' && !heatmapLoaded && hasTeamAccess) { + loadHeatmap(); + setHeatmapLoaded(true); + } + }, [ + tab, + activityLoaded, + heatmapLoaded, + hasTeamAccess, + loadActivity, + loadHeatmap, + ]); + + // Auto-accept invite from URL ?invite= + useEffect(() => { + if (!inviteToken) return; + const capturedToken = inviteToken; + const capturedApiBase = getApiBaseUrl(); + (async () => { + setInviteAccepting(true); + try { + // Read token fresh inside the effect to avoid stale closure + const authToken = localStorage.getItem('accessToken'); + const authHeaders = { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }; + const res = await fetch(`${capturedApiBase}/api/team/invites/accept`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ token: capturedToken }), + }); + const data = await res.json(); + if (!res.ok) + throw new Error(data.message ?? 'Failed to accept the invite.'); + if (data.success) { + setInviteResult({ + ok: true, + message: + "You've joined the team! You can now collaborate with your teammates.", + }); + const meRes = await fetch(`${capturedApiBase}/api/auth/me`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + const meData = await meRes.json(); + if (meData.success && meData.user) updateUser(meData.user); + loadDashboard(); + } else { + setInviteResult({ + ok: false, + message: data.message ?? 'Failed to accept the invite.', + }); + } + } catch (err: any) { + setInviteResult({ + ok: false, + message: err.message ?? 'Network error. Please try again.', + }); + } finally { + setInviteAccepting(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── derived state ─────────────────────────────────────────────────────────── + + const myMembership = members.find(m => m.userId === user?.id); + const myRole = myMembership?.role ?? null; + const isOwnerRole = myRole === 'owner'; + const canManage = myRole === 'owner' || myRole === 'admin'; + + // ── actions ───────────────────────────────────────────────────────────────── + + const showActionError = useCallback((msg: string) => { + setActionError(msg); + if (actionErrorTimer.current) clearTimeout(actionErrorTimer.current); + actionErrorTimer.current = setTimeout(() => setActionError(null), 4000); + }, []); + + // Clean up timer on unmount + useEffect( + () => () => { + if (actionErrorTimer.current) clearTimeout(actionErrorTimer.current); + }, + [] + ); + + const handleInvite = async () => { + if (!inviteEmail.trim()) return; + const sentTo = inviteEmail.trim(); // capture before clearing (U2) + setInviteLoading(true); + setInviteMsg(null); + try { + const res = await fetch(`${apiBase}/api/team/invites`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: sentTo, role: inviteRole }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed to send invite.'); + if (data.success) { + setInvites(prev => [data.invite, ...prev]); + setInviteEmail(''); + setInviteRole('member'); + setShowInviteForm(false); + setInviteMsg({ type: 'ok', text: `Invite sent to ${sentTo}` }); + setTimeout(() => setInviteMsg(null), 5000); + } else { + setInviteMsg({ + type: 'err', + text: data.message ?? 'Failed to send invite.', + }); + } + } catch (err: any) { + setInviteMsg({ + type: 'err', + text: err.message ?? 'Network error. Please try again.', + }); + } finally { + setInviteLoading(false); + } + }; + + const handleRemoveMember = async (memberId: string) => { + setRemovingId(memberId); + try { + const res = await fetch(`${apiBase}/api/team/members/${memberId}`, { + method: 'DELETE', + headers, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed to remove member.'); + if (data.success) { + setMembers(prev => prev.filter(m => m.userId !== memberId)); + setConfirmRemoveId(null); + } else { + showActionError(data.message ?? 'Failed to remove member.'); + } + } catch (err: any) { + showActionError(err.message ?? 'Network error. Could not remove member.'); + } finally { + setRemovingId(null); + } + }; + + const handleChangeRole = async (memberId: string, newRole: string) => { + if (roleChangingId) return; // prevent concurrent role changes + setRoleChangingId(memberId); + try { + const res = await fetch(`${apiBase}/api/team/members/${memberId}/role`, { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed to update role.'); + if (data.success) { + setMembers(prev => + prev.map(m => (m.userId === memberId ? { ...m, role: newRole } : m)) + ); + } else { + showActionError(data.message ?? 'Failed to update role.'); + } + } catch (err: any) { + showActionError(err.message ?? 'Network error. Could not update role.'); + } finally { + setRoleChangingId(null); + } + }; + + const handleCancelInvite = async (inviteId: string) => { + setCancellingId(inviteId); + try { + const res = await fetch(`${apiBase}/api/team/invites/${inviteId}`, { + method: 'DELETE', + headers, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message ?? 'Failed to cancel invite.'); + if (data.success) { + setInvites(prev => prev.filter(i => i.id !== inviteId)); + } else { + showActionError(data.message ?? 'Failed to cancel invite.'); + } + } catch (err: any) { + showActionError(err.message ?? 'Network error. Could not cancel invite.'); + } finally { + setCancellingId(null); + } + }; + + // ── render ─────────────────────────────────────────────────────────────────── + + const renderContent = () => { + // Invite accept flow — always shown when arriving via ?invite= + if (inviteToken && (inviteAccepting || inviteResult)) { + return ( +
+ {inviteAccepting ? ( + <> +
+
+
+

+ Accepting invitation… +

+ + ) : inviteResult?.ok ? ( + <> +
+
+ +
+
+

+ Invitation accepted! +

+

+ {inviteResult.message} +

+ + + ) : ( + <> +
+
+ +
+
+

+ Invite could not be accepted +

+

+ {inviteResult?.message} +

+ + )} +
+ ); + } + + if (loading) { + return ; + } + + if (error) { + return ( +
+
+
+ +
+
+

{error}

+ +
+ ); + } + + if (!isEnterprise && !hasTeamAccess) { + return ; + } + + const tabs: { id: Tab; icon: React.ElementType; label: string }[] = [ + { + id: 'members', + icon: Users, + label: `Members${members.length ? ` (${members.length})` : ''}`, + }, + ...(canManage + ? [ + { + id: 'invites' as Tab, + icon: Mail, + label: `Pending${invites.length ? ` (${invites.length})` : ''}`, + }, + ] + : []), + { id: 'activity', icon: Activity, label: 'Activity' }, + ]; + + return ( + <> + {/* Team notice for non-owner members */} + {(myRole === 'member' || myRole === 'admin') && teamData && ( +
+ + + You are a{' '} + {myRole} of{' '} + + {teamData.name} + + +
+ )} + + {/* Action error toast */} + {actionError && ( +
+ +

{actionError}

+
+ )} + + {/* Tab bar */} +
+ {tabs.map(t => ( + + ))} +
+ + {/* ── Members tab ──────────────────────────────────────────────────── */} + {tab === 'members' && ( +
+ {/* Invite controls — only for owner/admin */} + {canManage && ( +
+ {!showInviteForm ? ( + + ) : ( +
+

+ Invite a new member +

+
+ setInviteEmail(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleInvite()} + className="flex-1 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-600 focus:outline-none focus:border-white/20 transition-colors" + /> + +
+ {inviteMsg && ( +

+ {inviteMsg.type === 'ok' ? ( + + ) : ( + + )} + {inviteMsg.text} +

+ )} +
+ + +
+
+ )} + {inviteMsg?.type === 'ok' && !showInviteForm && ( +

+ {' '} + {inviteMsg.text} +

+ )} +
+ )} + + {/* Members table */} +
+
+ Member + Role + Joined + {canManage && Actions} +
+ {members.map((m, i) => { + const isSelf = m.userId === user?.id; + const isThisOwner = m.userId === teamData?.ownerId; + const showActions = canManage && !isSelf && !isThisOwner; + return ( +
+
+
+ {(m.user.fullName ?? m.user.email) + .charAt(0) + .toUpperCase()} +
+
+

+ {m.user.fullName ?? m.user.email} + {isSelf && ( + + (you) + + )} +

+ {m.user.fullName && ( +

+ {m.user.email} +

+ )} +
+
+
+ +
+ + {formatRelative(m.joinedAt)} + + {canManage && ( +
+ {showActions ? ( + confirmRemoveId === m.userId ? ( +
+ + +
+ ) : ( +
+ {isOwnerRole && ( + + )} + +
+ ) + ) : ( + + )} +
+ )} +
+ ); + })} +
+ + {/* Usage summary */} + {usage && + isEnterprise && + (() => { + const sortedMembers = [...usage.byMember].sort( + (a, b) => b.totalTokens - a.totalTokens + ); + const activeMembers = sortedMembers.filter( + m => m.totalTokens > 0 + ); + const totalRequests = usage.byOperation.reduce( + (s, o) => s + o.count, + 0 + ); + + const UsageBar = ({ pct }: { pct: number }) => ( +
+
+
+
+ + {pct}% + +
+ ); + + const ModelOpSection = ({ + byModel, + byOperation, + }: { + byModel: UsageModel[]; + byOperation: UsageOperation[]; + }) => ( +
0 ? 'grid-cols-2' : 'grid-cols-1'} divide-x divide-white/[0.06]`} + > + {byModel.length > 0 && ( +
+

+ By Model +

+
+ {byModel.map(m => { + const maxTok = byModel[0]?.totalTokens ?? 1; + const pct = Math.round( + (m.totalTokens / maxTok) * 100 + ); + const avg = + m.requestCount > 0 + ? Math.round(m.totalTokens / m.requestCount) + : 0; + return ( +
+
+

+ {m.model} +

+
+

+ {m.totalTokens.toLocaleString()} tok +

+

+ ${m.estimatedCost.toFixed(4)} ·{' '} + {m.requestCount} req ·{' '} + {avg.toLocaleString()} t/req +

+
+
+ +
+ ); + })} +
+
+ )} + {byOperation.length > 0 && ( +
+

+ Operations +

+
+ {byOperation.map(op => { + const maxCount = byOperation[0]?.count ?? 1; + const pct = Math.round((op.count / maxCount) * 100); + const totalOps = byOperation.reduce( + (s, o) => s + o.count, + 0 + ); + const share = + totalOps > 0 + ? Math.round((op.count / totalOps) * 100) + : 0; + return ( +
+
+ + {op.operationType.replace(/_/g, ' ')} + + + {share}% · {op.count.toLocaleString()} + +
+ +
+ ); + })} +
+
+ )} +
+ ); + + return ( +
+ {/* ── Header + tabs ── */} +
+

+ Team Usage — Last 30 Days +

+ {/* Member tab bar */} +
+ + {sortedMembers.map(m => ( + + ))} +
+
+ + {/* ── All Members view ── */} + {usageTab === 'all' && ( + <> + {/* 4-stat strip */} +
+ {[ + { + label: 'Total Tokens', + value: usage.totalTokens.toLocaleString(), + }, + { + label: 'Estimated Cost', + value: `$${usage.totalCost.toFixed(4)}`, + }, + { + label: 'Total Requests', + value: totalRequests.toLocaleString(), + }, + { + label: 'Active Members', + value: `${activeMembers.length} / ${members.length}`, + }, + ].map(s => ( +
+

+ {s.label} +

+

+ {s.value} +

+
+ ))} +
+ + {/* Per-member list */} + {activeMembers.length > 0 && ( +
+

+ By Member +

+ {/* Table header */} +
+
+ Member +
+
+ Tokens +
+
+ Cost +
+
+ Requests +
+
+ Avg tok/req +
+
+
+ {activeMembers.map(m => { + const pct = + usage.totalTokens > 0 + ? Math.round( + (m.totalTokens / usage.totalTokens) * + 100 + ) + : 0; + const initials = (m.fullName ?? m.email) + .split(/\s|@/)[0] + .slice(0, 2) + .toUpperCase(); + const avg = + m.requestCount > 0 + ? Math.round(m.totalTokens / m.requestCount) + : 0; + const role = + members.find(tm => tm.userId === m.userId) + ?.role ?? 'member'; + return ( +
+
+
+
+ {initials} +
+
+
+

+ {m.fullName ?? + m.email.split('@')[0]} +

+ + {role} + +
+ {m.fullName && ( +

+ {m.email} +

+ )} +
+
+
+

+ {m.totalTokens.toLocaleString()} +

+
+
+

+ ${m.estimatedCost.toFixed(4)} +

+
+
+

+ {m.requestCount.toLocaleString()} +

+
+
+

+ {avg.toLocaleString()} +

+
+
+
+ +
+
+ ); + })} +
+
+ )} + + {/* Team-wide model + ops */} + + + )} + + {/* ── Per-member view ── */} + {usageTab !== 'all' && + (() => { + const selectedMember = sortedMembers.find( + m => m.userId === usageTab + ); + const detail = memberUsageCache[usageTab]; + const role = + members.find(tm => tm.userId === usageTab)?.role ?? + 'member'; + const initials = selectedMember + ? (selectedMember.fullName ?? selectedMember.email) + .split(/\s|@/)[0] + .slice(0, 2) + .toUpperCase() + : ''; + const avg = + selectedMember && selectedMember.requestCount > 0 + ? Math.round( + selectedMember.totalTokens / + selectedMember.requestCount + ) + : 0; + return ( + <> + {/* Member stat strip */} +
+
+
+ {initials} +
+
+
+

+ {selectedMember?.fullName ?? + selectedMember?.email.split('@')[0]} +

+ + {role} + +
+ {selectedMember?.fullName && ( +

+ {selectedMember.email} +

+ )} +
+
+
+ {[ + { + label: 'Tokens', + value: + selectedMember?.totalTokens.toLocaleString() ?? + '—', + }, + { + label: 'Estimated Cost', + value: selectedMember + ? `$${selectedMember.estimatedCost.toFixed(4)}` + : '—', + }, + { + label: 'Requests', + value: + selectedMember?.requestCount.toLocaleString() ?? + '—', + }, + { + label: 'Avg tok/req', + value: avg > 0 ? avg.toLocaleString() : '—', + }, + ].map(s => ( +
+

+ {s.label} +

+

+ {s.value} +

+
+ ))} +
+
+ + {/* Detail breakdown */} + {memberUsageLoading ? ( +
+

+ Loading... +

+
+ ) : detail ? ( + + ) : ( +
+

+ No usage data for this period. +

+
+ )} + + ); + })()} +
+ ); + })()} +
+ )} + + {/* ── Invites tab ──────────────────────────────────────────────────── */} + {tab === 'invites' && ( +
+ {invites.length === 0 ? ( +
+ +

+ No pending invitations. +

+
+ ) : ( +
+
+ Email + Role + Invited by + Expires + Action +
+ {invites.map((inv, i) => ( +
+ + {inv.email} + +
+ +
+ + {inv.invitedBy.fullName ?? inv.invitedBy.email} + + + {formatRelative(inv.expiresAt)} + + +
+ ))} +
+ )} +
+ )} + + {/* ── Activity tab ─────────────────────────────────────────────────── */} + {tab === 'activity' && ( +
+ {/* 30-day heatmap */} + {heatmapError ? ( +
+

+ Failed to load activity heatmap. +

+ +
+ ) : ( + (() => { + const days = 30; + const now = new Date(); + const buckets = Array.from({ length: days }, (_, i) => { + const d = new Date(now); + d.setDate(d.getDate() - (days - 1 - i)); + const key = d.toISOString().slice(0, 10); + const found = heatmap.find(h => h.date === key); + return { + key, + label: d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), + count: found?.count ?? 0, + }; + }); + const total = buckets.reduce((s, b) => s + b.count, 0); + const cellColor = (count: number) => { + if (count === 0) return 'bg-white/[0.04]'; + if (count <= 2) return 'bg-white/[0.12]'; + if (count <= 5) return 'bg-teal-500/40'; + if (count <= 10) return 'bg-teal-400/60'; + return 'bg-teal-400/90'; + }; + return ( +
+
+

+ Activity — Last 30 Days +

+

+ {total} event{total !== 1 ? 's' : ''} +

+
+
+ {buckets.map(b => ( +
+ ))} +
+
+ + {buckets[0]?.label} + +
+ + Less + +
+
+
+
+
+ + More + +
+ + {buckets[buckets.length - 1]?.label} + +
+
+ ); + })() + )} + + {/* Audit log */} + {activityLoading ? ( +
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : activityError ? ( +
+

{activityError}

+ +
+ ) : activity.length === 0 ? ( +
+ +

+ No team activity recorded yet. +

+
+ ) : ( +
+

+ {(activityPage - 1) * 20 + 1}– + {Math.min(activityPage * 20, activityTotal)} of{' '} + {activityTotal} events +

+
+ {/* Header */} +
+
Event
+
Member
+
IP
+
Time
+
+ {/* Rows */} + {activity.map((log, i) => { + const meta = actionLabel[log.action] ?? { + label: log.action.replace(/_/g, ' '), + color: 'text-neutral-400', + }; + return ( +
+
+ + {meta.label} + + {log.details && ( +

+ {log.details} +

+ )} +
+
+ {log.user.fullName ?? log.user.email} +
+
+ {log.ipAddress ?? '—'} +
+
+ {formatDate(log.createdAt)} +
+
+ ); + })} +
+ + {activityTotalPages > 1 && ( +
+ + + Page {activityPage} of {activityTotalPages} + + +
+ )} +
+ )} +
+ )} + + ); + }; + + return ( + +
+ + {/* Header */} +
+
+ +
+
+

+ Team Management +

+

+ Manage members, roles, and team activity. +

+
+
+ + {renderContent()} +
+
+
+ ); +}; + +export default TeamManagement; diff --git a/src/components/Usage.tsx b/src/components/Usage.tsx index cdbd0d1..232c57a 100644 --- a/src/components/Usage.tsx +++ b/src/components/Usage.tsx @@ -442,8 +442,9 @@ const Usage: React.FC = () => { const [modelSaving, setModelSaving] = useState(false); const [modelSaved, setModelSaved] = useState(false); - const isPro = user?.plan === 'pro' || user?.plan === 'enterprise'; - const plan = user?.plan ?? 'free'; + const effectivePlan = user?.effectivePlan ?? user?.plan ?? 'free'; + const isPro = effectivePlan === 'pro' || effectivePlan === 'enterprise'; + const plan = effectivePlan; const quota = PLAN_QUOTA[plan] ?? null; const periodLabel = PERIODS.find(p => p.days === activePeriod)?.label ?? '30d'; diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index f585cb0..cf457e4 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -27,6 +27,9 @@ interface User { trialEnd?: string | null; // Date string from JSON preferredModel?: string | null; oauthProvider?: string | null; + teamRole?: string | null; + teamName?: string | null; + effectivePlan?: string | null; } interface AuthContextType { diff --git a/src/services/apiKey.service.ts b/src/services/apiKey.service.ts index 6ce49d0..876e3b5 100644 --- a/src/services/apiKey.service.ts +++ b/src/services/apiKey.service.ts @@ -8,10 +8,18 @@ interface ApiKeyResponse { keyPrefix: string; environment: 'test' | 'live'; revoked: boolean; + teamId?: string | null; lastUsedAt: string | null; createdAt: string; updatedAt: string; key?: string; // Only present when creating a new key + user?: { id: string; email: string; fullName: string | null }; +} + +interface ListTeamKeysResponse { + success: boolean; + apiKeys?: ApiKeyResponse[]; + error?: string; } interface CreateKeyResponse { @@ -171,6 +179,64 @@ export const revokeApiKey = async ( } }; +/** + * List team-scoped API keys (owner/admin only) + */ +export const listTeamApiKeys = async (): Promise => { + try { + const token = getAuthToken(); + const headers: HeadersInit = token + ? { Authorization: `Bearer ${token}` } + : {}; + const response = await fetch(`${API_BASE_URL}/api/keys/team`, { + method: 'GET', + headers, + credentials: 'include', + }); + const data = await response.json(); + if (!response.ok) + throw new Error(data.error || 'Failed to fetch team API keys'); + return data; + } catch (error: any) { + return { + success: false, + error: error.message || 'Failed to fetch team API keys', + }; + } +}; + +/** + * Create a team-scoped API key (owner/admin only). + * Backend resolves teamId from the authenticated user's membership. + */ +export const createTeamApiKey = async ( + name: string, + environment: 'test' | 'live' +): Promise => { + try { + const token = getAuthToken(); + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + const response = await fetch(`${API_BASE_URL}/api/keys`, { + method: 'POST', + headers, + credentials: 'include', + body: JSON.stringify({ name, environment, teamScoped: true }), + }); + const data = await response.json(); + if (!response.ok) + throw new Error(data.error || 'Failed to create team API key'); + return data; + } catch (error: any) { + return { + success: false, + error: error.message || 'Failed to create team API key', + }; + } +}; + /** * Delete an API key */