Surface team seat mismatch UX#506
Conversation
📝 WalkthroughWalkthroughThis 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. ChangesTeam Seat Mismatch Detection & Billing Integration
Sequence DiagramsequenceDiagram
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
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Deploying maple with
|
| 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 |
| 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}` | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 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."
| 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}` | |
| ); | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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 winMark 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 winUse 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 winSwitch 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
📒 Files selected for processing (8)
frontend/src/app.tsxfrontend/src/billing/billingPortal.tsfrontend/src/components/AccountMenu.tsxfrontend/src/components/team/TeamDashboard.tsxfrontend/src/components/team/TeamInviteDialog.tsxfrontend/src/components/team/TeamSeatMismatchAlert.tsxfrontend/src/types/team.tsfrontend/src/utils/teamSeats.ts
| const seatsAvailable = Math.max(0, teamStatus?.seats_available || 0); | ||
| const canOpenBillingPortal = billingStatus | ||
| ? !!billingStatus.stripe_customer_id | ||
| : !!teamStatus?.has_team_subscription; |
There was a problem hiding this comment.
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.
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.
Summary by CodeRabbit
New Features
Chores