Skip to content

Surface team seat mismatch UX#506

Open
AnthonyRonning wants to merge 1 commit into
masterfrom
team-seat-mismatch-ux
Open

Surface team seat mismatch UX#506
AnthonyRonning wants to merge 1 commit into
masterfrom
team-seat-mismatch-ux

Conversation

@AnthonyRonning
Copy link
Copy Markdown
Contributor

@AnthonyRonning AnthonyRonning commented May 6, 2026

Summary: Adds persistent team seat mismatch alert, explicit admin/member team dashboard warnings, account-menu paused state, tolerant seat-count helpers, and a shared billing portal opener. Tests: bun run typecheck; bun run lint (0 errors, existing warnings); pre-commit build and bun test passed.


Open in Devin Review

Summary by CodeRabbit

  • New Features

    • Team seat mismatch alerts notify users when member count exceeds purchased seats
    • Billing portal access integrated directly into team management interface
    • Enhanced team dashboard with seat usage visibility and management controls
    • Seat availability warnings added to invite workflow
  • Chores

    • Updated team status data model with seat count information

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

📝 Walkthrough

Walkthrough

This PR adds team seat mismatch detection and alerting throughout the frontend. A new utility module calculates when team member count exceeds purchased seats. New UI alerts appear in the app header, dashboard, and invite dialogs. TeamStatus is extended with seat and member count fields, and a billing portal helper simplifies subscription access.

Changes

Team Seat Mismatch Detection & Billing Integration

Layer / File(s) Summary
Data Shape
frontend/src/types/team.ts
TeamStatus extended with billed_seat_count and team_member_count fields.
Utility Calculation
frontend/src/utils/teamSeats.ts
New types TeamSeatCounts and TeamSeatMismatch with helpers getTeamSeatCounts, getTeamSeatMismatch, and formatTeamSeatMismatchMessage to detect when member count exceeds billed seats and format messaging for admin vs. member audiences.
Billing Portal Helper
frontend/src/billing/billingPortal.ts
New openBillingPortal function fetches portal URL from billing service and opens it externally, replacing Tauri-specific portal handling.
Component Updates
frontend/src/components/AccountMenu.tsx, frontend/src/components/team/TeamDashboard.tsx, frontend/src/components/team/TeamInviteDialog.tsx
Components integrate seat mismatch detection and billing portal access: AccountMenu shows mismatch badges, TeamDashboard displays seat-usage warnings and action buttons, TeamInviteDialog gates the manage-subscription flow behind canOpenBillingPortal.
New Alert Component
frontend/src/components/team/TeamSeatMismatchAlert.tsx
New component fetches team status, detects mismatch, and renders a fixed alert with admin/member-specific messaging and management dialog toggle.
App Integration
frontend/src/app.tsx
TeamSeatMismatchAlert rendered in component tree after DeepLinkHandler.

Sequence Diagram

sequenceDiagram
    participant App as App Component
    participant Alert as TeamSeatMismatchAlert
    participant BillingService as BillingService
    participant Portal as ExternalBrowser
    participant Dashboard as TeamDashboard
    
    App->>Alert: Render alert component
    Alert->>BillingService: Fetch team status
    BillingService-->>Alert: Return team status
    Alert->>Alert: Calculate seat mismatch<br/>(getTeamSeatMismatch)
    
    alt Mismatch Detected
        Alert->>Alert: Render warning banner<br/>and "Add Seats" button
        Note over Alert: User sees alert in app header
    else No Mismatch
        Alert->>Alert: Return null (no render)
    end
    
    Dashboard->>Dashboard: Compute seat metrics<br/>on load
    Dashboard->>Dashboard: Show seat-usage bar<br/>with mismatch warning
    
    Dashboard->>Alert: (or user from AccountMenu)
    Dashboard->>Dashboard: User clicks "Add Seats"
    Dashboard->>BillingService: openBillingPortal()
    BillingService->>BillingService: getPortalUrl()
    BillingService->>Portal: openExternalUrl(portalUrl)
    Portal-->>Portal: User manages subscription
    
    Dashboard->>BillingService: Invalidate queries<br/>(billingStatus, teamStatus)
    BillingService-->>Dashboard: Refresh team/billing data
    Dashboard->>Dashboard: Recompute seat mismatch
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

  • OpenSecretCloud/Maple#131: Introduces foundational team-management components and account menu billing integration that this PR extends with seat-mismatch detection and portal management.
  • OpenSecretCloud/Maple#177: Modifies AccountMenu to source billingStatus via useLocalState, which directly impacts how seat mismatch detection retrieves and displays billing information in this PR.
  • OpenSecretCloud/Maple#73: Adjusts Tauri/iOS billing portal URL opening logic; this PR abstracts portal opening into a helper function, replacing platform-specific code touched in that PR.

Poem

🐰 The rabbit hops through seat counts with care,
Alert banners flutter in app-level air,
When members outnumber the seats that are bought,
A mismatch is found and a warning is brought,
Portal doors open—add seats, manage free!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Surface team seat mismatch UX' accurately and concisely summarizes the main change: adding UI to display team seat mismatch alerts and warnings across multiple components.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch team-seat-mismatch-ux

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying maple with  Cloudflare Pages  Cloudflare Pages

Latest commit: 712a1b1
Status: ✅  Deploy successful!
Preview URL: https://0970d88e.maple-ca8.pages.dev
Branch Preview URL: https://team-seat-mismatch-ux.maple-ca8.pages.dev

View logs

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +57 to +73
if (
mismatch.hasExactCounts &&
mismatch.memberCount !== null &&
mismatch.billedSeatCount !== null
) {
const memberLabel = mismatch.memberCount === 1 ? "member" : "members";
const seatLabel = mismatch.billedSeatCount === 1 ? "paid seat" : "paid seats";
const resolution =
audience === "admin"
? "Team usage is paused until seats are added or members are removed."
: "Contact your team admin to add paid seats or remove members.";

return (
`This team has ${mismatch.memberCount} ${memberLabel} but only ` +
`${mismatch.billedSeatCount} ${seatLabel}. ${resolution}`
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 formatTeamSeatMismatchMessage shows misleading "but only" when seat_limit_exceeded flag triggers but client counts don't confirm mismatch

When seat_limit_exceeded is true from the server but the client-side counts show memberCount <= billedSeatCount, getTeamSeatMismatch (teamSeats.ts:45) still returns a mismatch with hasExactCounts: true. The formatTeamSeatMismatchMessage function (teamSeats.ts:69-72) then produces "This team has 3 members but only 5 paid seats." — the phrase "but only" implies the seat count is less than the member count, which is factually wrong when seats >= members.

This can happen when the server's seat_limit_exceeded flag is based on a different counting method (e.g. including pending invites) than the team_member_count/billed_seat_count fields sent to the client. The hasExactCounts flag at teamSeats.ts:39 means "both numbers exist", but formatTeamSeatMismatchMessage treats it as "the numbers confirm the mismatch."

Suggested change
if (
mismatch.hasExactCounts &&
mismatch.memberCount !== null &&
mismatch.billedSeatCount !== null
) {
const memberLabel = mismatch.memberCount === 1 ? "member" : "members";
const seatLabel = mismatch.billedSeatCount === 1 ? "paid seat" : "paid seats";
const resolution =
audience === "admin"
? "Team usage is paused until seats are added or members are removed."
: "Contact your team admin to add paid seats or remove members.";
return (
`This team has ${mismatch.memberCount} ${memberLabel} but only ` +
`${mismatch.billedSeatCount} ${seatLabel}. ${resolution}`
);
}
if (
mismatch.hasExactCounts &&
mismatch.memberCount !== null &&
mismatch.billedSeatCount !== null &&
mismatch.memberCount > mismatch.billedSeatCount
) {
const memberLabel = mismatch.memberCount === 1 ? "member" : "members";
const seatLabel = mismatch.billedSeatCount === 1 ? "paid seat" : "paid seats";
const resolution =
audience === "admin"
? "Team usage is paused until seats are added or members are removed."
: "Contact your team admin to add paid seats or remove members.";
return (
`This team has ${mismatch.memberCount} ${memberLabel} but only ` +
`${mismatch.billedSeatCount} ${seatLabel}. ${resolution}`
);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/team/TeamInviteDialog.tsx (1)

188-197: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mark the billing CTA as type="button".

This button lives inside the invite form, so clicking it also triggers handleInvite() today.
That can surface a bogus validation error while the user is trying to open billing.

💡 Suggested fix
                 {canOpenBillingPortal && (
                   <Button
+                    type="button"
                     variant="outline"
                     size="sm"
                     className="w-full"
                     onClick={handleManageSubscription}
                     disabled={isPortalLoading}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/team/TeamInviteDialog.tsx` around lines 188 - 197,
The billing CTA Button inside the invite form is missing an explicit type so
clicking it triggers the form submit (invoking handleInvite); update the Button
component rendering for the billing action (the instance that calls
handleManageSubscription and uses isPortalLoading/ CreditCard) to include
type="button" to prevent form submission and avoid spurious validation errors.
🧹 Nitpick comments (2)
frontend/src/components/team/TeamSeatMismatchAlert.tsx (1)

9-9: ⚡ Quick win

Use the repo alias for this import.

Keeping new TSX imports on @/components/... avoids path churn when the component tree moves.

♻️ Suggested change
-import { TeamManagementDialog } from "./TeamManagementDialog";
+import { TeamManagementDialog } from "@/components/team/TeamManagementDialog";

As per coding guidelines, Use path aliases (@/* maps to ./src/*) for imports in TypeScript/React files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/team/TeamSeatMismatchAlert.tsx` at line 9, The import
in TeamSeatMismatchAlert.tsx uses a relative path; update the import of
TeamManagementDialog to use the repo path alias (rooted at `@/`) so it follows the
TypeScript/React guideline—replace the current import of TeamManagementDialog
with the alias-based import (e.g., import from
"@/components/team/TeamManagementDialog") so future file moves won't break
paths.
frontend/src/app.tsx (1)

18-18: ⚡ Quick win

Switch this new import to the @/ alias.

That keeps the app entrypoint aligned with the repo's TS import convention and avoids brittle
relative paths.

♻️ Suggested change
-import { TeamSeatMismatchAlert } from "./components/team/TeamSeatMismatchAlert";
+import { TeamSeatMismatchAlert } from "@/components/team/TeamSeatMismatchAlert";

As per coding guidelines, Use path aliases (@/* maps to ./src/*) for imports in TypeScript/React files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app.tsx` at line 18, Replace the relative import of
TeamSeatMismatchAlert in frontend/src/app.tsx with the repository path-alias
form; locate the import statement importing TeamSeatMismatchAlert and change it
to use the "@/..." alias (which maps to ./src/) so the module is imported via
the alias rather than a relative path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/components/team/TeamInviteDialog.tsx`:
- Around line 35-38: The code currently sets seatsAvailable using only
teamStatus.seats_available and defaults missing data to 0, causing false "no
seats" when the API supplies billed_seat_count or team_member_count instead;
update the normalization logic used to compute seatsAvailable (the variable
computed from teamStatus) to derive available seats from the available fields in
order of preference: use teamStatus.seats_available if present, else compute
seats from teamStatus.billed_seat_count minus teamStatus.team_member_count (or
other business-rule delta) and clamp to Math.max(0, ...), and keep existing
usage sites (e.g., canOpenBillingPortal) unchanged so UI enables invites when
seats remain.

---

Outside diff comments:
In `@frontend/src/components/team/TeamInviteDialog.tsx`:
- Around line 188-197: The billing CTA Button inside the invite form is missing
an explicit type so clicking it triggers the form submit (invoking
handleInvite); update the Button component rendering for the billing action (the
instance that calls handleManageSubscription and uses isPortalLoading/
CreditCard) to include type="button" to prevent form submission and avoid
spurious validation errors.

---

Nitpick comments:
In `@frontend/src/app.tsx`:
- Line 18: Replace the relative import of TeamSeatMismatchAlert in
frontend/src/app.tsx with the repository path-alias form; locate the import
statement importing TeamSeatMismatchAlert and change it to use the "@/..." alias
(which maps to ./src/) so the module is imported via the alias rather than a
relative path.

In `@frontend/src/components/team/TeamSeatMismatchAlert.tsx`:
- Line 9: The import in TeamSeatMismatchAlert.tsx uses a relative path; update
the import of TeamManagementDialog to use the repo path alias (rooted at `@/`) so
it follows the TypeScript/React guideline—replace the current import of
TeamManagementDialog with the alias-based import (e.g., import from
"@/components/team/TeamManagementDialog") so future file moves won't break
paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b47fd06f-0741-41dc-94c1-2ea0da651265

📥 Commits

Reviewing files that changed from the base of the PR and between 715b13d and 712a1b1.

📒 Files selected for processing (8)
  • frontend/src/app.tsx
  • frontend/src/billing/billingPortal.ts
  • frontend/src/components/AccountMenu.tsx
  • frontend/src/components/team/TeamDashboard.tsx
  • frontend/src/components/team/TeamInviteDialog.tsx
  • frontend/src/components/team/TeamSeatMismatchAlert.tsx
  • frontend/src/types/team.ts
  • frontend/src/utils/teamSeats.ts

Comment on lines +35 to +38
const seatsAvailable = Math.max(0, teamStatus?.seats_available || 0);
const canOpenBillingPortal = billingStatus
? !!billingStatus.stripe_customer_id
: !!teamStatus?.has_team_subscription;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize seat availability instead of defaulting missing data to 0.

This dialog still reads only teamStatus.seats_available. If the API returns the new
billed_seat_count / team_member_count fields but omits seats_available, this will render
0 available seats, disable invites, and push admins into the billing flow even when seats remain.

💡 Suggested fix
 import { getBillingService } from "@/billing/billingService";
 import { openBillingPortal } from "@/billing/billingPortal";
 import { useLocalState } from "@/state/useLocalState";
 import type { TeamStatus } from "@/types/team";
+import { getTeamSeatCounts } from "@/utils/teamSeats";
@@
-  const seatsAvailable = Math.max(0, teamStatus?.seats_available || 0);
+  const seatCounts = getTeamSeatCounts(teamStatus);
+  const seatsAvailable =
+    seatCounts.seatsAvailable ??
+    (seatCounts.memberCount !== null && seatCounts.billedSeatCount !== null
+      ? Math.max(0, seatCounts.billedSeatCount - seatCounts.memberCount)
+      : 0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/team/TeamInviteDialog.tsx` around lines 35 - 38, The
code currently sets seatsAvailable using only teamStatus.seats_available and
defaults missing data to 0, causing false "no seats" when the API supplies
billed_seat_count or team_member_count instead; update the normalization logic
used to compute seatsAvailable (the variable computed from teamStatus) to derive
available seats from the available fields in order of preference: use
teamStatus.seats_available if present, else compute seats from
teamStatus.billed_seat_count minus teamStatus.team_member_count (or other
business-rule delta) and clamp to Math.max(0, ...), and keep existing usage
sites (e.g., canOpenBillingPortal) unchanged so UI enables invites when seats
remain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant