From c730693daa36981fb7d18dfd87ff8e39cedc31b2 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 1 Jul 2025 16:04:37 -0500 Subject: [PATCH 01/25] feat: remove team plan whitelist restrictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove fetchTeamPlanAvailable API endpoint and related code - Show team plans to all authenticated users on pricing page - Simplify team plan button logic to match other plans - Team plans now available for self-service purchase by any authenticated user This is the first step in implementing self-service team management features that will unlock B2B revenue opportunities. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .repo_ignore | 111 ++++++++++ TEAM_MANAGEMENT_REQUIREMENTS.md | 288 +++++++++++++++++++++++++ frontend/src/billing/billingApi.ts | 29 --- frontend/src/billing/billingService.ts | 7 +- frontend/src/routes/pricing.tsx | 75 +------ 5 files changed, 405 insertions(+), 105 deletions(-) create mode 100644 .repo_ignore create mode 100644 TEAM_MANAGEMENT_REQUIREMENTS.md diff --git a/.repo_ignore b/.repo_ignore new file mode 100644 index 000000000..9e4a6603d --- /dev/null +++ b/.repo_ignore @@ -0,0 +1,111 @@ +# Global ignore defaults +**/node_modules/ +**/.npm/ +**/__pycache__/ +**/.pytest_cache/ +**/.mypy_cache/ + +# Build caches +**/.gradle/ +**/.nuget/ +**/.cargo/ +**/.stack-work/ +**/.ccache/ + +# IDE and Editor caches +**/.idea/ +**/.vscode/ +**/*.swp +**/*~ + +# Temp files +**/*.tmp +**/*.temp +**/*.bak + +**/*.meta +**/package-lock.json + + +# Dependencies +node_modules/ +frontend/node_modules/ + +# Build outputs +/frontend/dist/ +/frontend/build/ + +# Environment variables +.env +.env.local +.env.*.local +*.key + +# IDE files +.vscode/ +.idea/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Operating system files +.DS_Store +Thumbs.db + +# JavaScript +package-lock.json + +# tests +/tests/test-results/ +/tests/playwright-report/ +/tests/blob-report/ +/tests/playwright/.cache/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyc + +# BrowserUse test artifacts +/tests/browseruse/logs/ +/tests/browseruse/*.json +/tests/browseruse/*.png +/tests/browseruse/test_summary.txt + + +# Logs +frontend/logs +frontend/*.log +frontend/npm-debug.log* +frontend/yarn-debug.log* +frontend/yarn-error.log* +frontend/pnpm-debug.log* +frontend/lerna-debug.log* + +frontend/dist +frontend/dist-ssr +frontend/*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.million + +# Build caching apparently? +*.tsbuildinfo + +**/.claude/settings.local.json + +frontend/src-tauri +.venv/ diff --git a/TEAM_MANAGEMENT_REQUIREMENTS.md b/TEAM_MANAGEMENT_REQUIREMENTS.md new file mode 100644 index 000000000..31d4e4716 --- /dev/null +++ b/TEAM_MANAGEMENT_REQUIREMENTS.md @@ -0,0 +1,288 @@ +# Team Management Self-Service Implementation Requirements + +## Overview +Implement self-service team management features allowing any authenticated user to purchase and manage teams without manual admin intervention. This is a critical B2B feature that will unlock millions in ARR. + +## Key Changes from Previous Behavior + +1. **Team Plan Visibility**: + - **REMOVE**: Any calls to `/subscription/team_plan_available` - this endpoint no longer exists + - **NEW**: Show team plans to ALL authenticated users on the pricing page + - No more whitelist checking needed + +2. **Team Status Detection**: + - The existing `/subscription/status` endpoint remains unchanged + - Use the new `/team/status` endpoint to get team-specific information + +## API Endpoints + +### 1. Team Status +``` +GET /team/status +Authorization: Bearer + +// Response when user has team plan but hasn't created team yet: +{ + "has_team_subscription": true, + "team_created": false, + "is_team_admin": true, + "seats_purchased": 5 +} + +// Response after team creation (admin): +{ + "has_team_subscription": true, + "team_created": true, + "team_id": "uuid", + "team_name": "My Awesome Team", + "role": "admin", + "seats_purchased": 5, + "seats_used": 1, + "seats_available": 4, + "members_count": 1, + "pending_invites_count": 0, + "created_at": "2025-07-01T...", + "seat_limit_exceeded": false +} +``` + +### 2. Create Team +``` +POST /team/create +Authorization: Bearer +Content-Type: application/json +{ + "name": "My Awesome Team" +} + +// Success Response: +{ + "team_id": "uuid", + "name": "My Awesome Team", + "created_at": "2025-07-01T..." +} +``` + +### 3. Invite Members +``` +POST /team/invites +Authorization: Bearer +Content-Type: application/json +{ + "emails": ["john@example.com", "jane@example.com"] +} + +// Response: +{ + "invites": [ + { + "invite_id": "uuid", + "email": "john@example.com", + "expires_at": "2025-07-08T...", + "status": "pending" + }, + ... + ] +} +``` + +### 4. List Members +``` +GET /team/members +Authorization: Bearer + +// Response: +{ + "members": [ + { + "user_id": "uuid", + "email": "admin@example.com", + "role": "admin", + "joined_at": "2025-07-01T..." + } + ], + "pending_invites": [ + { + "invite_id": "uuid", + "email": "john@example.com", + "expires_at": "2025-07-08T...", + "status": "pending" + } + ] +} +``` + +### 5. Check Invite +``` +GET /team/invites/{invite_id}/check +Authorization: Bearer + +// Response: +{ + "valid": true, + "team_name": "My Awesome Team", + "invited_by_name": "Admin User", + "expires_at": "2025-07-08T...", + "status": "pending" +} +``` + +### 6. Accept Invite +``` +POST /team/invites/{invite_id}/accept +Authorization: Bearer +Content-Type: application/json +{ + "email": "john@example.com" // User must confirm their email +} +``` + +### 7. Remove Member +``` +DELETE /team/members/{user_id} +Authorization: Bearer +``` + +### 8. Leave Team +``` +POST /team/leave +Authorization: Bearer +``` + +### 9. Revoke Invite +``` +DELETE /team/invites/{invite_id} +Authorization: Bearer +``` + +## Implementation Flow + +1. **Pricing Page**: Show team plans to all authenticated users +2. **After Purchase**: Check team status, if has_team_subscription but !team_created, show setup +3. **Team Setup**: Simple form for team name +4. **Team Dashboard**: Show after creation with member management +5. **Invite Flow**: Multi-email invite with seat checking +6. **Member Management**: List, remove, leave functionality +7. **Invite Acceptance**: Route handling with auth check + +## Critical UI/UX Requirements + +1. **Seat Counter**: Always show "X of Y seats used" +2. **Seat Limit Warning**: Red banner if seat_limit_exceeded +3. **Invite Management**: Show expiration countdown +4. **Error States**: Clear messages for all failure cases + +## Error Handling + +- Not enough seats: 400 Bad Request +- Already in a team: User can only be in one team +- Invite expired: Show appropriate message +- Team subscription cancelled: No new invites allowed + +## Testing Checklist + +- [ ] Purchase team plan as new user +- [ ] Create team with name +- [ ] Invite members up to seat limit +- [ ] Try to invite beyond seat limit (should fail) +- [ ] Accept invite from another account +- [ ] Remove a team member +- [ ] Member leaves team voluntarily +- [ ] Revoke a pending invite +- [ ] Check expired invite (after 7 days) +- [ ] Test seat limit exceeded state + +## Implementation Status + +- [ ] Task 1: Remove team_plan_available usage +- [ ] Task 2: Create team API functions +- [ ] Task 3: Create type definitions +- [ ] Task 4: Add team status check after purchase +- [ ] Task 5: Create TeamSetupDialog +- [ ] Task 6: Create /team/settings route +- [ ] Task 7: Create TeamDashboard +- [ ] Task 8: Implement invite functionality +- [ ] Task 9: Create TeamMembersList +- [ ] Task 10: Implement remove member +- [ ] Task 11: Implement leave team +- [ ] Task 12: Create invite acceptance route +- [ ] Task 13: Create InvitePreview +- [ ] Task 14: Add seat limit warning +- [ ] Task 15: Update AccountMenu +- [ ] Task 16: Implement revoke invite +- [ ] Task 17: Add error handling +- [ ] Task 18: Create TeamContext +- [ ] Task 19: Integrate team status +- [ ] Task 20: Write tests +- [ ] Task 21: Final review with git diff + +## Codebase Investigation Summary + +### Authentication & User Management +- Authentication via `@opensecret/react` SDK with JWT tokens +- User data accessible via `os.auth.user` from `useOpenSecret` hook +- Multiple auth providers: Email/Password, GitHub, Google, Apple +- Billing tokens stored separately in sessionStorage as `maple_billing_token` + +### Billing & Subscription System +- Billing API URL: `import.meta.env.VITE_MAPLE_BILLING_API_URL` +- BillingService singleton with automatic token refresh +- Supports Stripe and Zaprite payment providers +- Current team plan detection via `fetchTeamPlanAvailable()` (TO BE REMOVED) +- Billing status tracked in LocalStateContext + +### Routing Structure +- TanStack Router v1.50.1 with file-based routing +- Current routes: /, /pricing, /login, /signup, /chat/:chatId +- Need to add: /team/settings, /team/invite/:inviteId + +### UI Components Available +- Radix UI based: Dialog, Alert, Badge, Card, Select, DropdownMenu +- Forms: Input, Label, Button, Textarea +- Dark mode support with CSS variables +- Tailwind CSS styling +- Missing: Table component for member lists + +### State Management +- React Context API (no Redux) +- LocalStateContext manages user prompts, billing status, chat history +- TanStack Query for server state +- Need to create TeamContext for team state + +### API Communication Patterns +- Fetch API with Bearer token auth +- Error handling with try/catch +- TypeScript response typing +- BillingService handles token refresh on 401 + +### Existing Team Code +- Team plan in pricing at $30/user +- Team plan visibility controlled by whitelist +- "Contact Us" button for non-whitelisted users +- Features listed: "Collaboration features", "Shared history", "Team administration" + +### Key Implementation Files +``` +/src/billing/billingApi.ts - API endpoints +/src/billing/billingService.ts - Service layer +/src/routes/pricing.tsx - Pricing page +/src/components/AccountMenu.tsx - User menu +/src/state/localStateContext.tsx - Global state +``` + +### New Files to Create +``` +/src/types/team.ts - Team type definitions +/src/team/teamApi.ts - Team API endpoints +/src/state/TeamContext.tsx - Team state management +/src/components/team/* - Team UI components +/src/routes/team/settings.tsx - Team dashboard route +/src/routes/team/invite.$inviteId.tsx - Invite route +``` + +### Integration Points +1. After Stripe purchase success → Check team status +2. AccountMenu → Add team settings link +3. Pricing page → Remove whitelist check +4. LocalStateContext → Add team status +5. Navigation → Add team routes \ No newline at end of file diff --git a/frontend/src/billing/billingApi.ts b/frontend/src/billing/billingApi.ts index 613c93c92..da89c7af1 100644 --- a/frontend/src/billing/billingApi.ts +++ b/frontend/src/billing/billingApi.ts @@ -294,32 +294,3 @@ export async function createZapriteCheckoutSession( // Fall back to regular navigation if not on Tauri or if Tauri opener fails window.location.href = checkout_url; } - -export async function fetchTeamPlanAvailable(thirdPartyToken: string): Promise { - try { - const response = await fetch( - `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/team_plan_available`, - { - headers: { - Authorization: `Bearer ${thirdPartyToken}`, - "Content-Type": "application/json" - } - } - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Team plan availability error response:", errorText); - if (response.status === 401) { - throw new Error("Unauthorized"); - } - throw new Error(`Failed to check team plan availability: ${errorText}`); - } - - const { available } = await response.json(); - return available; - } catch (error) { - console.error("Error checking team plan availability:", error); - throw error; - } -} diff --git a/frontend/src/billing/billingService.ts b/frontend/src/billing/billingService.ts index c0c459a20..9b8a8d817 100644 --- a/frontend/src/billing/billingService.ts +++ b/frontend/src/billing/billingService.ts @@ -6,8 +6,7 @@ import { createCheckoutSession, createZapriteCheckoutSession, BillingStatus, - BillingProduct, - fetchTeamPlanAvailable + BillingProduct } from "./billingApi"; const TOKEN_STORAGE_KEY = "maple_billing_token"; @@ -92,10 +91,6 @@ class BillingService { ); } - async getTeamPlanAvailable(): Promise { - return this.executeWithToken((token) => fetchTeamPlanAvailable(token)); - } - clearToken(): void { sessionStorage.removeItem(TOKEN_STORAGE_KEY); } diff --git a/frontend/src/routes/pricing.tsx b/frontend/src/routes/pricing.tsx index 79a762b15..e14926906 100644 --- a/frontend/src/routes/pricing.tsx +++ b/frontend/src/routes/pricing.tsx @@ -255,21 +255,6 @@ function PricingPage() { enabled: isLoggedIn }); - // Check team plan availability if user is logged in - const { data: isTeamPlanAvailable } = useQuery({ - queryKey: ["teamPlanAvailable"], - queryFn: async () => { - const billingService = getBillingService(); - try { - return await billingService.getTeamPlanAvailable(); - } catch (error) { - console.error("Error checking team plan availability:", error); - return false; - } - }, - enabled: isLoggedIn - }); - const { data: products, error: productsError, @@ -304,11 +289,8 @@ function PricingPage() { const targetPlanName = product.name.toLowerCase(); const isTeamPlan = targetPlanName.includes("team"); - // Always show Contact Us for team plan when not logged in + // Show Start Chatting for all plans when not logged in if (!isLoggedIn) { - if (isTeamPlan) { - return "Contact Us"; - } return "Start Chatting"; } @@ -320,13 +302,8 @@ function PricingPage() { return "Contact Us"; } - // For team plan, ALWAYS show Contact Us if not whitelisted - // regardless of current subscription status + // For team plan if (isTeamPlan) { - if (!isTeamPlanAvailable) { - return "Contact Us"; - } - // Only show upgrade/downgrade for team plan if explicitly whitelisted if (isCurrentPlan) { return "Manage Plan"; } @@ -381,18 +358,6 @@ function PricingPage() { throw new Error("User email not found"); } - // Find the product to check if it's a team plan - const product = products?.find((p) => p.id === productId); - if (product && product.name.toLowerCase().includes("team")) { - // Double-check team plan availability before proceeding - const isAllowed = await billingService.getTeamPlanAvailable(); - if (!isAllowed) { - throw new Error( - "You are not authorized to purchase the Team plan. Please contact support for assistance." - ); - } - } - try { // Check if we're in a Tauri environment const isTauri = await import("@tauri-apps/api/core") @@ -464,20 +429,13 @@ function PricingPage() { setLoadingProductId(null); } }, - [isLoggedIn, navigate, os.auth.user?.user.email, useBitcoin, products] + [isLoggedIn, navigate, os.auth.user?.user.email, useBitcoin] ); const handleButtonClick = useCallback( (product: Product) => { if (!isLoggedIn) { const targetPlanName = product.name.toLowerCase(); - const isTeamPlan = targetPlanName.includes("team"); - - // For team plan, redirect to email when not logged in - if (isTeamPlan) { - window.location.href = "mailto:support@opensecret.cloud"; - return; - } if (!targetPlanName.includes("free")) { // For paid plans, redirect to signup with the plan selection @@ -495,17 +453,6 @@ function PricingPage() { } const targetPlanName = product.name.toLowerCase(); - const isTeamPlan = targetPlanName.includes("team"); - - // For team plan, ALWAYS redirect to email if not whitelisted - // regardless of current subscription status - if (isTeamPlan) { - if (!isTeamPlanAvailable) { - window.location.href = "mailto:support@opensecret.cloud"; - return; - } - // Only allow team plan checkout if explicitly whitelisted - } // If user is on Zaprite plan, redirect to email if (freshBillingStatus?.payment_provider === "zaprite") { @@ -559,15 +506,7 @@ function PricingPage() { // create checkout session newHandleSubscribe(product.id); }, - [ - isLoggedIn, - isTeamPlanAvailable, - freshBillingStatus, - navigate, - portalUrl, - newHandleSubscribe, - isIOS - ] + [isLoggedIn, freshBillingStatus, navigate, portalUrl, newHandleSubscribe, isIOS] ); useEffect(() => { @@ -893,11 +832,7 @@ function PricingPage() { transition-all duration-300 shadow-[0_0_15px_rgba(var(--purple-rgb),0.2)] hover:shadow-[0_0_25px_rgba(var(--purple-rgb),0.3)] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 - group-hover:bg-[hsl(var(--purple))] group-hover:text-[hsl(var(--foreground))] dark:group-hover:text-[hsl(var(--foreground))] dark:group-hover:bg-[hsl(var(--purple))]/80 ${ - isTeamPlan && !isTeamPlanAvailable && !isIOS - ? "!opacity-100 !cursor-pointer hover:!bg-[hsl(var(--purple))]" - : "" - }`} + group-hover:bg-[hsl(var(--purple))] group-hover:text-[hsl(var(--foreground))] dark:group-hover:text-[hsl(var(--foreground))] dark:group-hover:bg-[hsl(var(--purple))]/80`} > {useBitcoin && isTeamPlan ? "Not Available" From 5bc67968d527aea2226d1a0b03ef6c0e2208730a Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 1 Jul 2025 16:13:45 -0500 Subject: [PATCH 02/25] feat: add team management API functions and types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create comprehensive TypeScript interfaces for team management - Add all team API endpoints to billingApi.ts: - fetchTeamStatus: Get current team status - createTeam: Create a new team - inviteTeamMembers: Send invitations to multiple members - fetchTeamMembers: Get team members and pending invites - checkTeamInvite: Validate an invitation - acceptTeamInvite: Accept a team invitation - removeTeamMember: Remove a member (admin only) - leaveTeam: Leave current team - revokeTeamInvite: Cancel pending invitation (admin only) - Add corresponding wrapper methods in billingService.ts - Implement proper error handling for all endpoints This completes tasks 2 and 3 of the team management implementation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/billing/billingApi.ts | 258 +++++++++++++++++++++++++ frontend/src/billing/billingService.ts | 58 +++++- frontend/src/types/team.ts | 64 ++++++ 3 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 frontend/src/types/team.ts diff --git a/frontend/src/billing/billingApi.ts b/frontend/src/billing/billingApi.ts index da89c7af1..d84e873af 100644 --- a/frontend/src/billing/billingApi.ts +++ b/frontend/src/billing/billingApi.ts @@ -294,3 +294,261 @@ export async function createZapriteCheckoutSession( // Fall back to regular navigation if not on Tauri or if Tauri opener fails window.location.href = checkout_url; } + +// Team Management API Functions +import type { + TeamStatus, + CreateTeamRequest, + CreateTeamResponse, + InviteMembersRequest, + InviteMembersResponse, + TeamMembersResponse, + CheckInviteResponse, + AcceptInviteRequest +} from "@/types/team"; + +export async function fetchTeamStatus(thirdPartyToken: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/status`, + { + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Team status error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to fetch team status: ${errorText}`); + } + + return response.json(); +} + +export async function createTeam( + thirdPartyToken: string, + data: CreateTeamRequest +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/create`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Create team error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to create team: ${errorText}`); + } + + return response.json(); +} + +export async function inviteTeamMembers( + thirdPartyToken: string, + data: InviteMembersRequest +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Invite members error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 400) { + // Parse error message for user-friendly display + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.message || errorText); + } catch { + throw new Error(errorText); + } + } + throw new Error(`Failed to invite members: ${errorText}`); + } + + return response.json(); +} + +export async function fetchTeamMembers(thirdPartyToken: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/members`, + { + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Fetch team members error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to fetch team members: ${errorText}`); + } + + return response.json(); +} + +export async function checkTeamInvite( + thirdPartyToken: string, + inviteId: string +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites/${inviteId}/check`, + { + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Check invite error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to check invite: ${errorText}`); + } + + return response.json(); +} + +export async function acceptTeamInvite( + thirdPartyToken: string, + inviteId: string, + data: AcceptInviteRequest +): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites/${inviteId}/accept`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Accept invite error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 400) { + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.message || errorText); + } catch { + throw new Error(errorText); + } + } + throw new Error(`Failed to accept invite: ${errorText}`); + } + + return response.json(); +} + +export async function removeTeamMember(thirdPartyToken: string, userId: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/members/${userId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Remove member error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 403) { + throw new Error("Only team admins can remove members"); + } + throw new Error(`Failed to remove member: ${errorText}`); + } +} + +export async function leaveTeam(thirdPartyToken: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/leave`, + { + method: "POST", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Leave team error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + throw new Error(`Failed to leave team: ${errorText}`); + } +} + +export async function revokeTeamInvite(thirdPartyToken: string, inviteId: string): Promise { + const response = await fetch( + `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/team/invites/${inviteId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${thirdPartyToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Revoke invite error response:", errorText); + if (response.status === 401) { + throw new Error("Unauthorized"); + } + if (response.status === 403) { + throw new Error("Only team admins can revoke invites"); + } + throw new Error(`Failed to revoke invite: ${errorText}`); + } +} diff --git a/frontend/src/billing/billingService.ts b/frontend/src/billing/billingService.ts index 9b8a8d817..a270c256e 100644 --- a/frontend/src/billing/billingService.ts +++ b/frontend/src/billing/billingService.ts @@ -6,8 +6,27 @@ import { createCheckoutSession, createZapriteCheckoutSession, BillingStatus, - BillingProduct + BillingProduct, + fetchTeamStatus, + createTeam, + inviteTeamMembers, + fetchTeamMembers, + checkTeamInvite, + acceptTeamInvite, + removeTeamMember, + leaveTeam, + revokeTeamInvite } from "./billingApi"; +import type { + TeamStatus, + CreateTeamRequest, + CreateTeamResponse, + InviteMembersRequest, + InviteMembersResponse, + TeamMembersResponse, + CheckInviteResponse, + AcceptInviteRequest +} from "@/types/team"; const TOKEN_STORAGE_KEY = "maple_billing_token"; @@ -94,6 +113,43 @@ class BillingService { clearToken(): void { sessionStorage.removeItem(TOKEN_STORAGE_KEY); } + + // Team Management Methods + async getTeamStatus(): Promise { + return this.executeWithToken((token) => fetchTeamStatus(token)); + } + + async createTeam(data: CreateTeamRequest): Promise { + return this.executeWithToken((token) => createTeam(token, data)); + } + + async inviteTeamMembers(data: InviteMembersRequest): Promise { + return this.executeWithToken((token) => inviteTeamMembers(token, data)); + } + + async getTeamMembers(): Promise { + return this.executeWithToken((token) => fetchTeamMembers(token)); + } + + async checkTeamInvite(inviteId: string): Promise { + return this.executeWithToken((token) => checkTeamInvite(token, inviteId)); + } + + async acceptTeamInvite(inviteId: string, data: AcceptInviteRequest): Promise { + return this.executeWithToken((token) => acceptTeamInvite(token, inviteId, data)); + } + + async removeTeamMember(userId: string): Promise { + return this.executeWithToken((token) => removeTeamMember(token, userId)); + } + + async leaveTeam(): Promise { + return this.executeWithToken((token) => leaveTeam(token)); + } + + async revokeTeamInvite(inviteId: string): Promise { + return this.executeWithToken((token) => revokeTeamInvite(token, inviteId)); + } } // Singleton instance diff --git a/frontend/src/types/team.ts b/frontend/src/types/team.ts new file mode 100644 index 000000000..ad9b8e823 --- /dev/null +++ b/frontend/src/types/team.ts @@ -0,0 +1,64 @@ +export interface TeamStatus { + has_team_subscription: boolean; + team_created: boolean; + team_id?: string; + team_name?: string; + role?: "admin" | "member"; + seats_purchased?: number; + seats_used?: number; + seats_available?: number; + members_count?: number; + pending_invites_count?: number; + created_at?: string; + seat_limit_exceeded?: boolean; + is_team_admin?: boolean; +} + +export interface TeamMember { + user_id: string; + email: string; + role: "admin" | "member"; + joined_at: string; +} + +export interface TeamInvite { + invite_id: string; + email: string; + expires_at: string; + status: "pending" | "accepted" | "expired"; +} + +export interface TeamMembersResponse { + members: TeamMember[]; + pending_invites: TeamInvite[]; +} + +export interface CreateTeamRequest { + name: string; +} + +export interface CreateTeamResponse { + team_id: string; + name: string; + created_at: string; +} + +export interface InviteMembersRequest { + emails: string[]; +} + +export interface InviteMembersResponse { + invites: TeamInvite[]; +} + +export interface CheckInviteResponse { + valid: boolean; + team_name?: string; + invited_by_name?: string; + expires_at?: string; + status?: "pending" | "accepted" | "expired"; +} + +export interface AcceptInviteRequest { + email: string; +} From 28e3ca1e1bdbe3a3751c03627300768258684cff Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 1 Jul 2025 16:21:23 -0500 Subject: [PATCH 03/25] docs: update team management flow to use AccountMenu integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change approach from intercepting pricing page success to AccountMenu - Team management now centralized in AccountMenu/AccountDialog - Add alert badge for users who purchased team plan but haven't set up team - Update task list to reflect new architecture - All team features accessible from one logical location This provides better UX by keeping team features discoverable and centralized. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- TEAM_MANAGEMENT_REQUIREMENTS.md | 45 +++++++++++++++++---------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/TEAM_MANAGEMENT_REQUIREMENTS.md b/TEAM_MANAGEMENT_REQUIREMENTS.md index 31d4e4716..78032d124 100644 --- a/TEAM_MANAGEMENT_REQUIREMENTS.md +++ b/TEAM_MANAGEMENT_REQUIREMENTS.md @@ -158,8 +158,11 @@ Authorization: Bearer ## Implementation Flow 1. **Pricing Page**: Show team plans to all authenticated users -2. **After Purchase**: Check team status, if has_team_subscription but !team_created, show setup -3. **Team Setup**: Simple form for team name +2. **Account Menu Integration**: + - Add "Manage Team" option for users with team plan + - Show alert badge if team plan purchased but team not created + - All team features accessible from AccountMenu/AccountDialog +3. **Team Setup**: Simple form for team name when first accessing team management 4. **Team Dashboard**: Show after creation with member management 5. **Invite Flow**: Multi-email invite with seat checking 6. **Member Management**: List, remove, leave functionality @@ -194,27 +197,25 @@ Authorization: Bearer ## Implementation Status -- [ ] Task 1: Remove team_plan_available usage -- [ ] Task 2: Create team API functions -- [ ] Task 3: Create type definitions -- [ ] Task 4: Add team status check after purchase +- [x] Task 1: Remove team_plan_available usage +- [x] Task 2: Create team API functions +- [x] Task 3: Create type definitions +- [ ] Task 4: Update AccountMenu with team management integration - [ ] Task 5: Create TeamSetupDialog -- [ ] Task 6: Create /team/settings route -- [ ] Task 7: Create TeamDashboard -- [ ] Task 8: Implement invite functionality -- [ ] Task 9: Create TeamMembersList -- [ ] Task 10: Implement remove member -- [ ] Task 11: Implement leave team -- [ ] Task 12: Create invite acceptance route -- [ ] Task 13: Create InvitePreview -- [ ] Task 14: Add seat limit warning -- [ ] Task 15: Update AccountMenu -- [ ] Task 16: Implement revoke invite -- [ ] Task 17: Add error handling -- [ ] Task 18: Create TeamContext -- [ ] Task 19: Integrate team status -- [ ] Task 20: Write tests -- [ ] Task 21: Final review with git diff +- [ ] Task 6: Create TeamDashboard component +- [ ] Task 7: Implement invite functionality +- [ ] Task 8: Create TeamMembersList +- [ ] Task 9: Implement remove member +- [ ] Task 10: Implement leave team +- [ ] Task 11: Create invite acceptance route +- [ ] Task 12: Create InvitePreview +- [ ] Task 13: Add seat limit warning +- [ ] Task 14: Implement revoke invite +- [ ] Task 15: Add error handling +- [ ] Task 16: Create TeamContext +- [ ] Task 17: Integrate team status +- [ ] Task 18: Write tests +- [ ] Task 19: Final review with git diff ## Codebase Investigation Summary From 6ea2682d42caa359bc02f5ce3d672929494ab988 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 1 Jul 2025 16:24:23 -0500 Subject: [PATCH 04/25] feat: add team management integration to AccountMenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add team status fetching for users with team plan - Show "Manage Team" menu option for team plan users - Display alert badge with "Setup Required" when team plan purchased but not configured - Import necessary team types and icons (Users, AlertCircle) - Add placeholder for TeamManagementDialog component - Refetch team status every 30 seconds to stay updated This completes Task 4 - the AccountMenu now shows team management options with visual indicators for unconfigured teams. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/AccountMenu.tsx | 62 +++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 671f46ad2..c014f3d88 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -1,4 +1,13 @@ -import { LogOut, Trash, User, CreditCard, ArrowUpCircle, Mail } from "lucide-react"; +import { + LogOut, + Trash, + User, + CreditCard, + ArrowUpCircle, + Mail, + Users, + AlertCircle +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -29,10 +38,11 @@ import { AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { useLocalState } from "@/state/useLocalState"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { getBillingService } from "@/billing/billingService"; import { useState } from "react"; +import type { TeamStatus } from "@/types/team"; function ConfirmDeleteDialog() { const { clearHistory } = useLocalState(); @@ -70,13 +80,30 @@ export function AccountMenu() { const router = useRouter(); const { billingStatus } = useLocalState(); const [isPortalLoading, setIsPortalLoading] = useState(false); + // const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false); // TODO: Uncomment when TeamManagementDialog is ready const hasStripeAccount = billingStatus?.stripe_customer_id !== null; const productName = billingStatus?.product_name || ""; const isPro = productName.toLowerCase().includes("pro"); const isStarter = productName.toLowerCase().includes("starter"); - const showUpgrade = !isPro; - const showManage = (isPro || isStarter) && hasStripeAccount; + const isTeamPlan = productName.toLowerCase().includes("team"); + const showUpgrade = !isPro && !isTeamPlan; + const showManage = (isPro || isStarter || isTeamPlan) && hasStripeAccount; + + // Fetch team status if user has team plan + const { data: teamStatus } = useQuery({ + queryKey: ["teamStatus"], + queryFn: async () => { + const billingService = getBillingService(); + return await billingService.getTeamStatus(); + }, + enabled: isTeamPlan && !!os.auth.user, + refetchInterval: 30000 // Refetch every 30 seconds to stay updated + }); + + // Show alert badge if user has team plan but hasn't created team yet + const showTeamSetupAlert = + isTeamPlan && teamStatus?.has_team_subscription && !teamStatus?.team_created; const handleManageSubscription = async () => { if (!hasStripeAccount) return; @@ -197,6 +224,27 @@ export function AccountMenu() { {isPortalLoading ? "Loading..." : "Manage Subscription"} )} + {isTeamPlan && ( + {/* TODO: setIsTeamDialogOpen(true) */}}> +
+
+ + Manage Team +
+ {showTeamSetupAlert && ( +
+ + + Setup Required + +
+ )} +
+
+ )} @@ -218,6 +266,12 @@ export function AccountMenu() { + {/* TODO: Add TeamManagementDialog component */} + {/* */} From 216a43de322cf16041f4027fa72cc07467ed7649 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 1 Jul 2025 16:26:51 -0500 Subject: [PATCH 05/25] fix: remove unnecessary team status polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove refetchInterval from team status query - Team status now only fetches on page load - Reduces unnecessary network requests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/AccountMenu.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index c014f3d88..2a511c63d 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -97,8 +97,7 @@ export function AccountMenu() { const billingService = getBillingService(); return await billingService.getTeamStatus(); }, - enabled: isTeamPlan && !!os.auth.user, - refetchInterval: 30000 // Refetch every 30 seconds to stay updated + enabled: isTeamPlan && !!os.auth.user }); // Show alert badge if user has team plan but hasn't created team yet From 2beffce40b51ebdf69efb3d273a6832d2af4c753 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 1 Jul 2025 17:00:12 -0500 Subject: [PATCH 06/25] feat: implement self-service team management UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add team status API integration and type definitions - Create TeamSetupDialog for initial team creation after purchase - Implement comprehensive TeamDashboard with seat usage visualization - Add TeamInviteDialog with multi-email support and validation - Create TeamMembersList with member management (remove/leave/revoke) - Update AccountMenu with team management integration and alert badges - Fix pricing page button consistency (all say 'Upgrade' now) - Add proper permission checks for admin vs member actions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/TEAM_TESTING_CHECKLIST.md | 71 ++++ frontend/src/components/AccountMenu.tsx | 32 +- .../src/components/team/TeamDashboard.tsx | 166 ++++++++ .../src/components/team/TeamInviteDialog.tsx | 202 ++++++++++ .../components/team/TeamManagementDialog.tsx | 52 +++ .../src/components/team/TeamMembersList.tsx | 380 ++++++++++++++++++ .../src/components/team/TeamSetupDialog.tsx | 156 +++++++ frontend/src/routes/pricing.tsx | 2 +- 8 files changed, 1044 insertions(+), 17 deletions(-) create mode 100644 frontend/TEAM_TESTING_CHECKLIST.md create mode 100644 frontend/src/components/team/TeamDashboard.tsx create mode 100644 frontend/src/components/team/TeamInviteDialog.tsx create mode 100644 frontend/src/components/team/TeamManagementDialog.tsx create mode 100644 frontend/src/components/team/TeamMembersList.tsx create mode 100644 frontend/src/components/team/TeamSetupDialog.tsx diff --git a/frontend/TEAM_TESTING_CHECKLIST.md b/frontend/TEAM_TESTING_CHECKLIST.md new file mode 100644 index 000000000..e61c6adf0 --- /dev/null +++ b/frontend/TEAM_TESTING_CHECKLIST.md @@ -0,0 +1,71 @@ +# Team Management Testing Checklist + +## Initial Setup Flow +1. [ ] **Purchase Team Plan** + - Sign in as a regular user + - Go to /pricing + - Verify team plans are visible (no "Contact Us" button) + - Purchase a team plan through Stripe + +2. [ ] **Team Creation** + - After purchase, click "Manage Team" in AccountMenu + - Verify "Setup Required" badge appears + - Verify TeamSetupDialog opens automatically + - Enter a team name and create team + - Verify dialog closes and dashboard appears + +## Team Dashboard Testing +3. [ ] **Dashboard UI** + - Verify team name displays correctly + - Check "Admin" badge appears for creator + - Verify seat usage shows correctly (1/X seats used) + - Check member count shows 1 + - Verify "Invite Members" button is enabled + +4. [ ] **Invite Members** + - Click "Invite Members" button + - Test single email entry + - Test multiple emails (comma-separated) + - Test multiple emails (line-separated) + - Verify email validation works + - Check seat limit validation + - Send invites and verify success message + +5. [ ] **Member List** + - Verify your own account shows with "You" badge + - Check pending invites appear below members + - Verify expiration time shows for invites + - Test revoke invite functionality (X button) + +## Edge Cases to Test +6. [ ] **Seat Limits** + - Try inviting more members than available seats + - Verify error message appears + - Check if "Invite Members" button disables when at capacity + +7. [ ] **Refresh Behavior** + - Refresh page and verify team status persists + - Check that team dashboard loads correctly + - Verify member list refreshes after actions + +## What's NOT Ready Yet +- Invite acceptance (recipients can't join yet - Task 11) +- Removing members (backend might need testing) +- Leave team functionality (backend might need testing) +- Error toasts for better feedback (currently console.error) + +## Console Monitoring +Watch browser console for: +- API errors (401, 403, 404, etc.) +- Failed requests +- Any JavaScript errors + +## Quick Test Script +```bash +# In one terminal +cd frontend +bun run dev + +# Check for any compilation errors +# Open http://localhost:5173 +``` \ No newline at end of file diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 2a511c63d..1af04b245 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -43,6 +43,7 @@ import { Link } from "@tanstack/react-router"; import { getBillingService } from "@/billing/billingService"; import { useState } from "react"; import type { TeamStatus } from "@/types/team"; +import { TeamManagementDialog } from "@/components/team/TeamManagementDialog"; function ConfirmDeleteDialog() { const { clearHistory } = useLocalState(); @@ -80,7 +81,7 @@ export function AccountMenu() { const router = useRouter(); const { billingStatus } = useLocalState(); const [isPortalLoading, setIsPortalLoading] = useState(false); - // const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false); // TODO: Uncomment when TeamManagementDialog is ready + const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false); const hasStripeAccount = billingStatus?.stripe_customer_id !== null; const productName = billingStatus?.product_name || ""; @@ -193,9 +194,12 @@ export function AccountMenu() { - @@ -224,22 +228,19 @@ export function AccountMenu() { )} {isTeamPlan && ( - {/* TODO: setIsTeamDialogOpen(true) */}}> + setIsTeamDialogOpen(true)}>
Manage Team
{showTeamSetupAlert && ( -
- - - Setup Required - -
+ + Setup Required + )}
@@ -265,12 +266,11 @@ export function AccountMenu() { - {/* TODO: Add TeamManagementDialog component */} - {/* */} + /> diff --git a/frontend/src/components/team/TeamDashboard.tsx b/frontend/src/components/team/TeamDashboard.tsx new file mode 100644 index 000000000..fdc0d4409 --- /dev/null +++ b/frontend/src/components/team/TeamDashboard.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Users, UserPlus, Settings, AlertTriangle, Crown, User } from "lucide-react"; +import { TeamInviteDialog } from "./TeamInviteDialog"; +import { TeamMembersList } from "./TeamMembersList"; +import type { TeamStatus } from "@/types/team"; + +interface TeamDashboardProps { + teamStatus?: TeamStatus; +} + +export function TeamDashboard({ teamStatus }: TeamDashboardProps) { + const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); + + if (!teamStatus) { + return ( + <> + + Team Dashboard + Loading team information... + + + ); + } + + const seatsUsed = teamStatus.seats_used || 0; + const seatsPurchased = teamStatus.seats_purchased || 0; + const seatsAvailable = teamStatus.seats_available || 0; + const isAdmin = teamStatus.role === "admin" || teamStatus.is_team_admin === true; + const seatUsagePercentage = seatsPurchased > 0 ? (seatsUsed / seatsPurchased) * 100 : 0; + + return ( + <> + + Team Dashboard + Manage your team members and monitor seat usage + + +
+ {/* Seat limit exceeded warning */} + {teamStatus.seat_limit_exceeded && ( + + + Seat Limit Exceeded + + Your team has exceeded its seat limit. Please remove members or purchase additional + seats to continue inviting new members. + + + )} + + {/* Team overview */} + + +
+
+ {teamStatus.team_name} + + Created {new Date(teamStatus.created_at || "").toLocaleDateString()} + +
+ + {isAdmin ? ( + <> + + Admin + + ) : ( + <> + + Member + + )} + +
+
+
+ + {/* Seat usage */} + + + + + Seat Usage + + + +
+
+ + {seatsUsed} / {seatsPurchased} + + seats used +
+
+
= 100 + ? "bg-destructive" + : seatUsagePercentage >= 80 + ? "bg-amber-500" + : "bg-primary" + }`} + style={{ width: `${Math.min(seatUsagePercentage, 100)}%` }} + /> +
+ {seatsAvailable > 0 && ( +

+ {seatsAvailable} {seatsAvailable === 1 ? "seat" : "seats"} available +

+ )} +
+ + + + {/* Team statistics */} +
+ + +
{teamStatus.members_count || 0}
+

Active Members

+
+
+ + +
{teamStatus.pending_invites_count || 0}
+

Pending Invites

+
+
+
+ + {/* Action buttons */} + {isAdmin && ( +
+ + +
+ )} + + {/* Members list */} + +
+ + {/* Invite dialog */} + + + ); +} diff --git a/frontend/src/components/team/TeamInviteDialog.tsx b/frontend/src/components/team/TeamInviteDialog.tsx new file mode 100644 index 000000000..089a495dd --- /dev/null +++ b/frontend/src/components/team/TeamInviteDialog.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, AlertCircle, UserPlus, Info } from "lucide-react"; +import { getBillingService } from "@/billing/billingService"; +import type { TeamStatus } from "@/types/team"; + +interface TeamInviteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + teamStatus?: TeamStatus; +} + +export function TeamInviteDialog({ open, onOpenChange, teamStatus }: TeamInviteDialogProps) { + const [emails, setEmails] = useState(""); + const [isInviting, setIsInviting] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const queryClient = useQueryClient(); + + const seatsAvailable = teamStatus?.seats_available || 0; + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + + const emailList = emails + .split(/[\n,]/) + .map((email) => email.trim()) + .filter((email) => email.length > 0); + + if (emailList.length === 0) { + setError("Please enter at least one email address"); + return; + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalidEmails = emailList.filter((email) => !emailRegex.test(email)); + if (invalidEmails.length > 0) { + setError(`Invalid email format: ${invalidEmails.join(", ")}`); + return; + } + + // Check seat availability + if (emailList.length > seatsAvailable) { + setError( + `Cannot invite ${emailList.length} members. Only ${seatsAvailable} ${ + seatsAvailable === 1 ? "seat is" : "seats are" + } available.` + ); + return; + } + + setIsInviting(true); + setError(null); + setSuccessMessage(null); + + try { + const billingService = getBillingService(); + const response = await billingService.inviteTeamMembers({ emails: emailList }); + + // Invalidate team status and members queries + await queryClient.invalidateQueries({ queryKey: ["teamStatus"] }); + await queryClient.invalidateQueries({ queryKey: ["teamMembers"] }); + + const inviteCount = response.invites.length; + setSuccessMessage( + `Successfully sent ${inviteCount} ${inviteCount === 1 ? "invite" : "invites"}` + ); + + // Clear the form + setEmails(""); + + // Close dialog after a short delay + setTimeout(() => { + onOpenChange(false); + setSuccessMessage(null); + }, 2000); + } catch (err) { + console.error("Failed to invite members:", err); + setError(err instanceof Error ? err.message : "Failed to send invites"); + } finally { + setIsInviting(false); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!isInviting) { + onOpenChange(newOpen); + // Reset form when closing + if (!newOpen) { + setEmails(""); + setError(null); + setSuccessMessage(null); + } + } + }; + + return ( + + + + Invite Team Members + + Send invitations to new team members. They'll receive an email to join your team. + + + +
+
+
+ +