Skip to content

Pro seat management and organization settings restructure#1640

Merged
richiemcilroy merged 38 commits intomainfrom
invite-users
Mar 3, 2026
Merged

Pro seat management and organization settings restructure#1640
richiemcilroy merged 38 commits intomainfrom
invite-users

Conversation

@richiemcilroy
Copy link
Member

@richiemcilroy richiemcilroy commented Mar 3, 2026

  • Adds per-member Pro seat assignment (hasProSeat column) with toggle in the members table, replacing the old seat-count-based invite system
    • Restructures organization settings into tabbed sub-pages (General, Preferences, Billing & Members) with a shared layout and auth guard
    • Adds Stripe-integrated seat quantity management with prorated billing previews
    • Rewrites invite sending and acceptance flows to use transactions with duplicate/existing-member dedup
    • Adds member avatars to the sidebar with quick-invite prompts

Greptile Summary

This PR replaces the old seat-count-based invite system with per-member Pro seat assignment (hasProSeat), restructures organization settings into tabbed sub-pages with a shared server-side auth guard layout, adds Stripe-integrated seat quantity management with prorated billing previews, and rewrites invite sending/acceptance flows to use transactions with proper deduplication and concurrency safety.

Key changes:

  • organization_members.hasProSeat column added via migration; toggle action uses FOR UPDATE locking on the full member list to prevent over-assignment race conditions
  • previewSeatChange / updateSeatQuantity correctly isolate proration cost from Stripe line items (not amount_due); DB update wrapped in try/catch to surface post-Stripe sync failures
  • Invite acceptance rewritten as a single DB transaction with row-locking; auto-assigns Pro seat for new members only; always switches activeOrganizationId
  • Org settings refactored into layout.tsx (server auth guard) + GeneralPage, PreferencesPage, BillingAndMembersPage sub-routes
  • InviteDialog validates email format client-side with two UX regressions: email input is unconditionally cleared even when all typed emails were invalid (user loses their text and must retype to correct), and when mixing valid and invalid emails, valid ones are silently queued while only an error toast fires (partial-success state is ambiguous)
  • MemberAvatars sidebar component correctly gates invite-slot buttons behind an ownership check
  • SettingsNav tab label conditionally renders "Billing & Members" vs "Members" based on NEXT_PUBLIC_IS_CAP

Confidence Score: 4/5

  • Safe to merge with minor UX polish required for the invite dialog component.
  • All critical backend logic and security concerns are properly implemented: Pro seat toggle uses correct transaction locking, Stripe/DB atomicity is handled, invite acceptance deduplication is sound, and auto-assignment only applies to new members. The only outstanding issues are UX regressions in the InviteDialog—input clearing on invalid emails and ambiguous partial-success feedback—which do not affect data integrity or billing correctness. These are straightforward fixes suitable for polish before release.
  • apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx requires UX improvements to the email input handling (conditional clearing + success feedback for partial validation).

Last reviewed commit: 4610f9c

const validEmails = invitedEmails.filter((email) =>
emailRegex.test(email.trim()),
);
const validEmails = invitedEmails
Copy link

Choose a reason for hiding this comment

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

validEmails can still contain duplicates (e.g. the same email pasted twice), which would create multiple invite rows in a single request. Consider deduping after normalization.

Suggested change
const validEmails = invitedEmails
const validEmails = Array.from(
new Set(
invitedEmails
.map((email) => email.trim().toLowerCase())
.filter((email) => emailRegex.test(email)),
),
);

}),
);

const failedEmails = inviteRecords
Copy link

Choose a reason for hiding this comment

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

Right now if sendEmail rejects, the invite row still exists, so retrying will be deduped away (unless there’s a separate resend flow). One option is to delete failed invite records so retries work.

Suggested change
const failedEmails = inviteRecords
const failedInvites = inviteRecords.filter(
(_, i) => emailResults[i]?.status === "rejected",
);
const failedEmails = failedInvites.map((r) => r.email);
if (failedInvites.length > 0) {
await db()
.delete(organizationInvites)
.where(inArray(organizationInvites.id, failedInvites.map((r) => r.id)));
}

@@ -17,78 +19,136 @@ export async function POST(request: NextRequest) {

const { inviteId } = await request.json();
Copy link

Choose a reason for hiding this comment

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

request.json() can throw (invalid JSON / empty body) and it’s currently outside the try, which turns a client error into a 500.

Suggested change
const { inviteId } = await request.json();
let inviteId: unknown;
try {
({ inviteId } = await request.json());
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

hasProSeat: organizationMembers.hasProSeat,
})
.from(organizationMembers)
.where(eq(organizationMembers.organizationId, organizationId));
Copy link

Choose a reason for hiding this comment

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

Seat assignment can race if two toggles run concurrently (both read the same proSeatsRemaining). Locking the org’s member rows here (like in the invite accept route) avoids oversubscription.

Suggested change
.where(eq(organizationMembers.organizationId, organizationId));
.where(eq(organizationMembers.organizationId, organizationId))
.for("update");

@paragon-review
Copy link

paragon-review bot commented Mar 3, 2026

Paragon Summary

This pull request review analyzed 29 files and found no issues. The review examined code changes, potential bugs, security vulnerabilities, performance issues, and code quality concerns using automated analysis tools.

Paragon did not detect any problems in the current diff. Proceed with merge after your normal checks.

This PR replaces the seat-count-based invite system with per-member Pro seat assignment (hasProSeat column), integrates Stripe for prorated seat billing, and restructures organization settings into tabbed sub-pages (General, Preferences, Billing & Members) with updated invite flows and member avatars in the sidebar.

Key changes:

  • Adds per-member Pro seat assignment (hasProSeat column) replacing seat-count-based invite system
  • Restructures organization settings into tabbed sub-pages (General, Preferences, Billing & Members)
  • Adds Stripe-integrated seat quantity management with prorated billing previews
  • Rewrites invite flows with transactional deduplication for duplicates/existing members
  • Adds member avatars to navbar with quick-invite prompts

Confidence score: 5/5

  • This PR has low risk with no critical or high-priority issues identified
  • Score reflects clean code review with only minor suggestions or no issues found
  • Code quality checks passed - safe to proceed with merge

29 files reviewed, 0 comments

Dashboard

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

29 files reviewed, 18 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +57 to +84
if (enable) {
const allMembers = await tx
.select({
id: organizationMembers.id,
hasProSeat: organizationMembers.hasProSeat,
})
.from(organizationMembers)
.where(eq(organizationMembers.organizationId, organizationId));

const [owner] = await tx
.select({
inviteQuota: users.inviteQuota,
stripeSubscriptionId: users.stripeSubscriptionId,
})
.from(users)
.where(eq(users.id, organization.ownerId))
.limit(1);

const { proSeatsRemaining } = calculateProSeats({
inviteQuota: owner?.inviteQuota ?? 1,
members: allMembers,
});

if (proSeatsRemaining <= 0) {
throw new Error(
"No Pro seats remaining. Purchase more seats to continue.",
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Race condition when checking available Pro seats

The allMembers query inside the transaction (line 58) is executed without a row lock, while the single member row is locked with .for("update") on line 47. Two concurrent requests can both read the same proSeatsRemaining > 0 value, pass the check, and each proceed to assign a seat — resulting in more seats consumed than the quota allows.

To prevent this, add a row lock to the allMembers query:

const allMembers = await tx
  .select({
    id: organizationMembers.id,
    hasProSeat: organizationMembers.hasProSeat,
  })
  .from(organizationMembers)
  .where(eq(organizationMembers.organizationId, organizationId))
  .for("update");

This serializes the seat availability check across concurrent requests.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/toggle-pro-seat.ts
Line: 57-84

Comment:
**Race condition when checking available Pro seats**

The `allMembers` query inside the transaction (line 58) is executed without a row lock, while the single `member` row is locked with `.for("update")` on line 47. Two concurrent requests can both read the same `proSeatsRemaining > 0` value, pass the check, and each proceed to assign a seat — resulting in more seats consumed than the quota allows.

To prevent this, add a row lock to the `allMembers` query:

```ts
const allMembers = await tx
  .select({
    id: organizationMembers.id,
    hasProSeat: organizationMembers.hasProSeat,
  })
  .from(organizationMembers)
  .where(eq(organizationMembers.organizationId, organizationId))
  .for("update");
```

This serializes the seat availability check across concurrent requests.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +57 to +89
if (enable) {
const allMembers = await tx
.select({
id: organizationMembers.id,
hasProSeat: organizationMembers.hasProSeat,
})
.from(organizationMembers)
.where(eq(organizationMembers.organizationId, organizationId));

const [owner] = await tx
.select({
inviteQuota: users.inviteQuota,
stripeSubscriptionId: users.stripeSubscriptionId,
})
.from(users)
.where(eq(users.id, organization.ownerId))
.limit(1);

const { proSeatsRemaining } = calculateProSeats({
inviteQuota: owner?.inviteQuota ?? 1,
members: allMembers,
});

if (proSeatsRemaining <= 0) {
throw new Error(
"No Pro seats remaining. Purchase more seats to continue.",
);
}

await tx
.update(organizationMembers)
.set({ hasProSeat: true })
.where(eq(organizationMembers.id, memberId));
Copy link
Contributor

Choose a reason for hiding this comment

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

No idempotency guard when enable = true and seat is already assigned

If member.hasProSeat is already true and toggleProSeat is called with enable = true (possible via direct API call or duplicate UI events), the code still runs the full seat-count check. If the organization is at exactly its quota limit, proSeatsRemaining will be 0 and the call throws "No Pro seats remaining" — even though no new seat is actually being consumed.

Add an early exit before the if (enable) block to short-circuit when state is already desired:

if (member.hasProSeat === enable) {
  // Already in the desired state, nothing to do
  return { success: true };
}

Insert this after the owner check (line 53) and before the if (enable) branch (line 57).

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/toggle-pro-seat.ts
Line: 57-89

Comment:
**No idempotency guard when `enable = true` and seat is already assigned**

If `member.hasProSeat` is already `true` and `toggleProSeat` is called with `enable = true` (possible via direct API call or duplicate UI events), the code still runs the full seat-count check. If the organization is at exactly its quota limit, `proSeatsRemaining` will be `0` and the call throws "No Pro seats remaining" — even though no new seat is actually being consumed.

Add an early exit before the `if (enable)` block to short-circuit when state is already desired:

```ts
if (member.hasProSeat === enable) {
  // Already in the desired state, nothing to do
  return { success: true };
}
```

Insert this after the owner check (line 53) and before the `if (enable)` branch (line 57).

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +132 to +145
await stripe().subscriptions.update(subscription.id, {
items: [
{
id: subscriptionItem.id,
quantity: newQuantity,
},
],
proration_behavior: "create_prorations",
});

await db()
.update(users)
.set({ inviteQuota: newQuantity })
.where(eq(users.id, user.id));
Copy link
Contributor

Choose a reason for hiding this comment

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

Stripe and database updates are not atomic

stripe().subscriptions.update() (line 132) and the subsequent db().update(users) (line 142) are executed as independent operations. If the database update fails after the Stripe subscription is already modified, the Stripe seat count and the local inviteQuota will be permanently out of sync — users could be billed for seats that don't appear in the product.

Wrap the DB update in a try/catch to detect and handle the failure:

await stripe().subscriptions.update(subscription.id, {
  items: [{ id: subscriptionItem.id, quantity: newQuantity }],
  proration_behavior: "create_prorations",
});

try {
  await db()
    .update(users)
    .set({ inviteQuota: newQuantity })
    .where(eq(users.id, user.id));
} catch (dbError) {
  console.error(
    "CRITICAL: Stripe updated to quantity",
    newQuantity,
    "but DB update failed for user",
    user.id,
    "—",
    dbError,
  );
  throw new Error("Billing update succeeded but local state could not be saved. Please contact support.");
}

This ensures a Stripe/DB divergence is logged and the client is aware of the failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/update-seat-quantity.ts
Line: 132-145

Comment:
**Stripe and database updates are not atomic**

`stripe().subscriptions.update()` (line 132) and the subsequent `db().update(users)` (line 142) are executed as independent operations. If the database update fails after the Stripe subscription is already modified, the Stripe seat count and the local `inviteQuota` will be permanently out of sync — users could be billed for seats that don't appear in the product.

Wrap the DB update in a try/catch to detect and handle the failure:

```ts
await stripe().subscriptions.update(subscription.id, {
  items: [{ id: subscriptionItem.id, quantity: newQuantity }],
  proration_behavior: "create_prorations",
});

try {
  await db()
    .update(users)
    .set({ inviteQuota: newQuantity })
    .where(eq(users.id, user.id));
} catch (dbError) {
  console.error(
    "CRITICAL: Stripe updated to quantity",
    newQuantity,
    "but DB update failed for user",
    user.id,
    "",
    dbError,
  );
  throw new Error("Billing update succeeded but local state could not be saved. Please contact support.");
}
```

This ensures a Stripe/DB divergence is logged and the client is aware of the failure.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +72 to +112
if (org && memberId) {
const [owner] = await tx
.select({
inviteQuota: users.inviteQuota,
stripeSubscriptionId: users.stripeSubscriptionId,
})
.from(users)
.where(eq(users.id, org.ownerId))
.limit(1);

if (owner?.stripeSubscriptionId) {
const allMembers = await tx
.select({
id: organizationMembers.id,
hasProSeat: organizationMembers.hasProSeat,
})
.from(organizationMembers)
.where(
eq(organizationMembers.organizationId, invite.organizationId),
)
.for("update");

const { proSeatsRemaining } = calculateProSeats({
inviteQuota: owner.inviteQuota ?? 1,
members: allMembers,
});

if (proSeatsRemaining > 0) {
await tx
.update(organizationMembers)
.set({ hasProSeat: true })
.where(eq(organizationMembers.id, memberId));

await tx
.update(users)
.set({
thirdPartyStripeSubscriptionId: owner.stripeSubscriptionId,
})
.where(eq(users.id, user.id));
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Pro seat auto-assigned to existing members on re-accept

When existingMembership is found (the user is already a member), memberId is set to the existing record's ID (line 53) and the code still proceeds to the Pro-seat check (lines 72–112). If the existing member has hasProSeat: false and there are seats available, the invite acceptance silently upgrades them to a Pro seat — even though the owner never explicitly granted that permission.

Skip the Pro-seat allocation block when existingMembership is truthy:

if (org && memberId && !existingMembership) {
  // only auto-assign Pro seat for genuinely new members
  const [owner] = await tx
    .select({
      inviteQuota: users.inviteQuota,
      stripeSubscriptionId: users.stripeSubscriptionId,
    })
    .from(users)
    .where(eq(users.id, org.ownerId))
    .limit(1);

  if (owner?.stripeSubscriptionId) {
    // ... existing seat logic ...
  }
}

This prevents unintended permission escalation for users re-accepting invites.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/invite/accept/route.ts
Line: 72-112

Comment:
**Pro seat auto-assigned to existing members on re-accept**

When `existingMembership` is found (the user is already a member), `memberId` is set to the existing record's ID (line 53) and the code still proceeds to the Pro-seat check (lines 72–112). If the existing member has `hasProSeat: false` and there are seats available, the invite acceptance silently upgrades them to a Pro seat — even though the owner never explicitly granted that permission.

Skip the Pro-seat allocation block when `existingMembership` is truthy:

```ts
if (org && memberId && !existingMembership) {
  // only auto-assign Pro seat for genuinely new members
  const [owner] = await tx
    .select({
      inviteQuota: users.inviteQuota,
      stripeSubscriptionId: users.stripeSubscriptionId,
    })
    .from(users)
    .where(eq(users.id, org.ownerId))
    .limit(1);

  if (owner?.stripeSubscriptionId) {
    // ... existing seat logic ...
  }
}
```

This prevents unintended permission escalation for users re-accepting invites.

How can I resolve this? If you propose a fix, please make it concise.

throw new Error("Only the owner can view subscription details");

const [owner] = await db()
.select({
Copy link

Choose a reason for hiding this comment

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

stripeSubscriptionStatus is selected but unused here; removing it avoids unused-data drift.

Suggested change
.select({
.select({
stripeSubscriptionId: users.stripeSubscriptionId,
})

status: subscription.status,
billingInterval: interval,
pricePerSeat,
currentQuantity: item.quantity || 1,
Copy link

Choose a reason for hiding this comment

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

Minor: || 1 treats 0 as missing. ?? 1 is safer for nullable quantities.

Suggested change
currentQuantity: item.quantity || 1,
currentQuantity: item.quantity ?? 1,

@richiemcilroy
Copy link
Member Author

@greptileai please review again

Comment on lines +136 to 145
if (failedInvites.length > 0) {
await db()
.delete(organizationInvites)
.where(
inArray(
organizationInvites.id,
failedInvites.map((r) => r.id),
),
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Unhandled cleanup failure can orphan invite records

When email delivery fails, the subsequent db().delete() cleanup (lines 136–145) runs outside the transaction with no error handling. If this delete fails due to a transient DB error, the undelivered invite records will persist indefinitely — creating a data integrity gap. A user who somehow obtains the invite ID (e.g., from a server log) could still accept the invite without having been notified.

Wrap the cleanup in a try/catch to detect and log the failure:

Suggested change
if (failedInvites.length > 0) {
await db()
.delete(organizationInvites)
.where(
inArray(
organizationInvites.id,
failedInvites.map((r) => r.id),
),
);
}
if (failedInvites.length > 0) {
try {
await db()
.delete(organizationInvites)
.where(
inArray(
organizationInvites.id,
failedInvites.map((r) => r.id),
),
);
} catch (cleanupError) {
console.error(
"CRITICAL: Failed to clean up undelivered invite records:",
failedInvites.map((r) => r.id),
cleanupError,
);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/send-invites.ts
Line: 136-145

Comment:
**Unhandled cleanup failure can orphan invite records**

When email delivery fails, the subsequent `db().delete()` cleanup (lines 136–145) runs outside the transaction with no error handling. If this delete fails due to a transient DB error, the undelivered invite records will persist indefinitely — creating a data integrity gap. A user who somehow obtains the invite ID (e.g., from a server log) could still accept the invite without having been notified.

Wrap the cleanup in a try/catch to detect and log the failure:

```suggestion
if (failedInvites.length > 0) {
  try {
    await db()
      .delete(organizationInvites)
      .where(
        inArray(
          organizationInvites.id,
          failedInvites.map((r) => r.id),
        ),
      );
  } catch (cleanupError) {
    console.error(
      "CRITICAL: Failed to clean up undelivered invite records:",
      failedInvites.map((r) => r.id),
      cleanupError,
    );
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +133 to +138
if (!user.activeOrganizationId) {
userUpdate.activeOrganizationId = invite.organizationId;
}
if (!user.defaultOrgId) {
userUpdate.defaultOrgId = invite.organizationId;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Accepting an invite never switches user context to the new organization

When a user accepts an invite for organization B while already having an activeOrganizationId (from organization A), they are silently added as a member of B but their context is never updated. After accepting, the user lands on their old organization's dashboard with no indication that they successfully joined the new one.

The code only updates activeOrganizationId and defaultOrgId if they are currently empty — which means existing users are never switched:

if (!user.activeOrganizationId) {
  userUpdate.activeOrganizationId = invite.organizationId;
}

To fix this regression, always switch context for a genuinely new membership:

Suggested change
if (!user.activeOrganizationId) {
userUpdate.activeOrganizationId = invite.organizationId;
}
if (!user.defaultOrgId) {
userUpdate.defaultOrgId = invite.organizationId;
}
// Always switch context for a genuinely new membership
if (!existingMembership) {
userUpdate.activeOrganizationId = invite.organizationId;
userUpdate.defaultOrgId = invite.organizationId;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/invite/accept/route.ts
Line: 133-138

Comment:
**Accepting an invite never switches user context to the new organization**

When a user accepts an invite for organization B while already having an `activeOrganizationId` (from organization A), they are silently added as a member of B but their context is never updated. After accepting, the user lands on their old organization's dashboard with no indication that they successfully joined the new one.

The code only updates `activeOrganizationId` and `defaultOrgId` if they are currently empty — which means existing users are never switched:

```ts
if (!user.activeOrganizationId) {
  userUpdate.activeOrganizationId = invite.organizationId;
}
```

To fix this regression, always switch context for a genuinely new membership:

```suggestion
// Always switch context for a genuinely new membership
if (!existingMembership) {
  userUpdate.activeOrganizationId = invite.organizationId;
  userUpdate.defaultOrgId = invite.organizationId;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +133 to +139
<button
type="button"
onClick={() => updateDesiredQuantity(desiredQuantity + 1)}
className="flex justify-center items-center w-8 h-8 rounded-r-md bg-gray-4 hover:bg-gray-5"
>
<Plus className="w-4 h-4 text-gray-12" />
</button>
Copy link
Contributor

Choose a reason for hiding this comment

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

Increment button lacks upper-bound guard, triggering rejected preview API calls

The decrease button correctly disables when !canDecrease, but the increase button has no corresponding guard. A user can click + an arbitrary number of times (e.g., to 501, 1000, …), each triggering a debounced preview API call that will be rejected server-side with a 400. This creates unnecessary Stripe API calls and a confusing UX where the preview label says "Calculating..." for requests that will ultimately fail.

Add a disabled check on the increment button to enforce the 500-seat maximum:

Suggested change
<button
type="button"
onClick={() => updateDesiredQuantity(desiredQuantity + 1)}
className="flex justify-center items-center w-8 h-8 rounded-r-md bg-gray-4 hover:bg-gray-5"
>
<Plus className="w-4 h-4 text-gray-12" />
</button>
<button
type="button"
onClick={() =>
desiredQuantity < 500 && updateDesiredQuantity(desiredQuantity + 1)
}
className="flex justify-center items-center w-8 h-8 rounded-r-md bg-gray-4 hover:bg-gray-5 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={desiredQuantity >= 500}
>
<Plus className="w-4 h-4 text-gray-12" />
</button>
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx
Line: 133-139

Comment:
**Increment button lacks upper-bound guard, triggering rejected preview API calls**

The decrease button correctly disables when `!canDecrease`, but the increase button has no corresponding guard. A user can click `+` an arbitrary number of times (e.g., to 501, 1000, …), each triggering a debounced preview API call that will be rejected server-side with a 400. This creates unnecessary Stripe API calls and a confusing UX where the preview label says "Calculating..." for requests that will ultimately fail.

Add a `disabled` check on the increment button to enforce the 500-seat maximum:

```suggestion
<button
  type="button"
  onClick={() =>
    desiredQuantity < 500 && updateDesiredQuantity(desiredQuantity + 1)
  }
  className="flex justify-center items-center w-8 h-8 rounded-r-md bg-gray-4 hover:bg-gray-5 disabled:opacity-50 disabled:cursor-not-allowed"
  disabled={desiredQuantity >= 500}
>
  <Plus className="w-4 h-4 text-gray-12" />
</button>
```

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Member Author

@greptileai please review again

const MAX_VISIBLE = 4;

export function MemberAvatars() {
const { activeOrganization, sidebarCollapsed, setInviteDialogOpen } =
Copy link

Choose a reason for hiding this comment

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

This shows an invite affordance in the sidebar; grabbing user here makes it easy to hide the invite prompt for non-owners.

Suggested change
const { activeOrganization, sidebarCollapsed, setInviteDialogOpen } =
const { activeOrganization, sidebarCollapsed, setInviteDialogOpen, user } =
useDashboardContext();

</div>
)}

{Array.from({ length: emptySlots }).map((_, i) => (
Copy link

Choose a reason for hiding this comment

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

Might be worth only rendering the empty invite slots for org owners; otherwise non-owners get a tooltip/button that will fail on submit.

Suggested change
{Array.from({ length: emptySlots }).map((_, i) => (
{activeOrganization?.organization.ownerId === user.id &&
Array.from({ length: emptySlots }).map((_, i) => (

Comment on lines +49 to +64
{Array.from({ length: emptySlots }).map((_, i) => (
<Tooltip
key={`empty-${i}`}
content="Invite to your organization"
position="bottom"
delayDuration={0}
>
<button
type="button"
onClick={() => setInviteDialogOpen(true)}
className="-ml-1.5 flex items-center justify-center size-6 rounded-full border border-dashed border-gray-8 bg-gray-3 hover:bg-gray-4 hover:border-gray-9 transition-colors"
>
<Plus className="size-3 text-gray-10" />
</button>
</Tooltip>
))}
Copy link
Contributor

Choose a reason for hiding this comment

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

Invite slots rendered for non-owner members

The empty-slot invite buttons are shown and clickable for all users. There is no ownership check, so regular members will see up to 4 dashed invite circles, click them, and submit — only to receive a server-level error.

The component has access to the user and organization data via useDashboardContext() (which includes both user and activeOrganization). Add an ownership check before rendering the invite buttons:

const { activeOrganization, user, sidebarCollapsed, setInviteDialogOpen } =
  useDashboardContext();

const isOwner = user?.id === activeOrganization?.organization.ownerId;

Then conditionally render the empty slot buttons only when isOwner is true.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx
Line: 49-64

Comment:
**Invite slots rendered for non-owner members**

The empty-slot invite buttons are shown and clickable for all users. There is no ownership check, so regular members will see up to 4 dashed invite circles, click them, and submit — only to receive a server-level error.

The component has access to the user and organization data via `useDashboardContext()` (which includes both `user` and `activeOrganization`). Add an ownership check before rendering the invite buttons:

```tsx
const { activeOrganization, user, sidebarCollapsed, setInviteDialogOpen } =
  useDashboardContext();

const isOwner = user?.id === activeOrganization?.organization.ownerId;
```

Then conditionally render the empty slot buttons only when `isOwner` is `true`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +22 to +56
const { data: subscription, isLoading } =
useQuery<SubscriptionDetails | null>({
queryKey: ["subscription-details", organizationId],
queryFn: () => {
if (!organizationId) return null;
return getSubscriptionDetails(organizationId);
},
enabled: !!organizationId,
staleTime: 60 * 1000,
});

const handleManageBilling = useCallback(async () => {
setBillingLoading(true);
try {
const url = await manageBilling();
router.push(url);
} catch {
toast.error("An error occurred while managing billing");
} finally {
setBillingLoading(false);
}
}, [router]);

if (isLoading) {
return (
<Card>
<div className="flex flex-col gap-3 animate-pulse">
<div className="h-5 w-32 bg-gray-4 rounded" />
<div className="h-4 w-48 bg-gray-4 rounded" />
<div className="h-4 w-40 bg-gray-4 rounded" />
</div>
</Card>
);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Query errors silently render the "Upgrade to Pro" upsell card

When getSubscriptionDetails throws (network error, Stripe outage, etc.), data becomes undefined and isLoading becomes false. The check !subscription then evaluates to true and renders the "Upgrade to Cap Pro" card. A Pro subscriber experiencing a temporary API error would see an upsell prompt, which is confusing.

Distinguish the error state by also reading isError from the query:

const { data: subscription, isLoading, isError } =
  useQuery<SubscriptionDetails | null>({ ... });

if (isError) {
  return (
    <Card>
      <p className="text-sm text-gray-10">
        Unable to load billing details. Please try again later.
      </p>
    </Card>
  );
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx
Line: 22-56

Comment:
**Query errors silently render the "Upgrade to Pro" upsell card**

When `getSubscriptionDetails` throws (network error, Stripe outage, etc.), `data` becomes `undefined` and `isLoading` becomes `false`. The check `!subscription` then evaluates to `true` and renders the "Upgrade to Cap Pro" card. A Pro subscriber experiencing a temporary API error would see an upsell prompt, which is confusing.

Distinguish the error state by also reading `isError` from the query:

```tsx
const { data: subscription, isLoading, isError } =
  useQuery<SubscriptionDetails | null>({ ... });

if (isError) {
  return (
    <Card>
      <p className="text-sm text-gray-10">
        Unable to load billing details. Please try again later.
      </p>
    </Card>
  );
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +67 to +156
const { data: preview, isFetching: previewLoading } = useQuery({
queryKey: ["seat-preview", organizationId, debouncedQuantity],
queryFn: () => {
if (!organizationId) return null;
return previewSeatChange(organizationId, debouncedQuantity);
},
enabled: !!organizationId && debouncedHasChanges,
staleTime: 30 * 1000,
});

const updateMutation = useMutation({
mutationFn: () => {
if (!organizationId) throw new Error("No organization");
return updateSeatQuantity(organizationId, desiredQuantity);
},
onSuccess: (result) => {
toast.success(`Seat quantity updated to ${result.newQuantity}`);
queryClient.setQueriesData<SubscriptionDetails | null>(
{ queryKey: ["subscription-details", organizationId] },
(old) => (old ? { ...old, currentQuantity: result.newQuantity } : old),
);
router.refresh();
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to update seats",
);
},
});

const MAX_SEATS = 500;
const canDecrease = desiredQuantity > Math.max(1, proSeatsUsed);
const canIncrease = desiredQuantity < MAX_SEATS;

return (
<Card>
<CardHeader>
<CardTitle>Pro Seats</CardTitle>
<CardDescription>
Manage how many Pro seats are available for your team.
</CardDescription>
</CardHeader>
<div className="flex flex-col gap-5 mt-4">
<div className="flex items-center gap-3 text-sm text-gray-11">
<span>
<span className="font-medium text-gray-12">{proSeatsUsed}</span> of{" "}
<span className="font-medium text-gray-12">{proSeatsTotal}</span>{" "}
Pro seats assigned
</span>
</div>

<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1">
<span className="mr-2 text-sm text-gray-12">Seats:</span>
<button
type="button"
onClick={() =>
canDecrease && updateDesiredQuantity(desiredQuantity - 1)
}
className="flex justify-center items-center w-8 h-8 rounded-l-md bg-gray-4 hover:bg-gray-5 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!canDecrease}
>
<Minus className="w-4 h-4 text-gray-12" />
</button>
<NumberFlow
value={desiredQuantity}
className="mx-auto w-8 text-sm tabular-nums text-center text-gray-12"
/>
<button
type="button"
onClick={() =>
canIncrease && updateDesiredQuantity(desiredQuantity + 1)
}
className="flex justify-center items-center w-8 h-8 rounded-r-md bg-gray-4 hover:bg-gray-5 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!canIncrease}
>
<Plus className="w-4 h-4 text-gray-12" />
</button>
</div>

{hasChanges && (
<div className="flex items-center gap-3">
{previewLoading ? (
<span className="text-sm text-gray-10">Calculating...</span>
) : preview ? (
<span className="text-sm text-gray-11">
Prorated charge: ${(preview.proratedAmount / 100).toFixed(2)}{" "}
{preview.currency.toUpperCase()}
</span>
) : null}
Copy link
Contributor

Choose a reason for hiding this comment

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

Preview query errors are not surfaced to the user

When previewSeatChange rejects (e.g., server-side validation fails), React Query enters an error state. However, the component only branches on previewLoading and preview, so when an error occurs, the JSX returns null with no message — giving no indication of why a preview isn't showing.

Read isError from the query and show an inline error message:

const { data: preview, isFetching: previewLoading, isError: previewError } = useQuery({ ... });

// In the JSX:
{previewLoading ? (
  <span className="text-sm text-gray-10">Calculating...</span>
) : previewError ? (
  <span className="text-sm text-red-500">Unable to calculate preview</span>
) : preview ? (
  <span className="text-sm text-gray-11">
    Prorated charge: ${(preview.proratedAmount / 100).toFixed(2)}{" "}
    {preview.currency.toUpperCase()}
  </span>
) : null}

Also consider disabling the "Confirm" button when previewError is true, since submitting in this state would produce a server-side error.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx
Line: 67-156

Comment:
**Preview query errors are not surfaced to the user**

When `previewSeatChange` rejects (e.g., server-side validation fails), React Query enters an error state. However, the component only branches on `previewLoading` and `preview`, so when an error occurs, the JSX returns `null` with no message — giving no indication of why a preview isn't showing.

Read `isError` from the query and show an inline error message:

```tsx
const { data: preview, isFetching: previewLoading, isError: previewError } = useQuery({ ... });

// In the JSX:
{previewLoading ? (
  <span className="text-sm text-gray-10">Calculating...</span>
) : previewError ? (
  <span className="text-sm text-red-500">Unable to calculate preview</span>
) : preview ? (
  <span className="text-sm text-gray-11">
    Prorated charge: ${(preview.proratedAmount / 100).toFixed(2)}{" "}
    {preview.currency.toUpperCase()}
  </span>
) : null}
```

Also consider disabling the "Confirm" button when `previewError` is true, since submitting in this state would produce a server-side error.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +102 to +125
} else {
await tx
.update(organizationMembers)
.set({ hasProSeat: false })
.where(eq(organizationMembers.id, memberId));

const otherProSeats = await tx
.select({ id: organizationMembers.id })
.from(organizationMembers)
.where(
and(
eq(organizationMembers.userId, member.userId),
eq(organizationMembers.hasProSeat, true),
),
)
.limit(1);

if (otherProSeats.length === 0) {
await tx
.update(users)
.set({ thirdPartyStripeSubscriptionId: null })
.where(eq(users.id, member.userId));
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Stale thirdPartyStripeSubscriptionId when user holds Pro seats across multiple orgs

When revoking a Pro seat, the query on lines 108–117 checks for any other hasProSeat = true record for the same userId without filtering by organization. If the user belongs to Org A (subscription X) and Org B (subscription Y), and you revoke their seat in Org A, the query finds their Org B seat and skips the subscription ID update — leaving thirdPartyStripeSubscriptionId unchanged, still pointing to Org A's subscription X. The user retains a subscription ID that no longer applies in the current organization context.

Update the code to set the correct subscription when other Pro seats exist:

if (otherProSeats.length === 0) {
  await tx
    .update(users)
    .set({ thirdPartyStripeSubscriptionId: null })
    .where(eq(users.id, member.userId));
} else {
  // Update to the subscription of a remaining Pro-seat org
  const [remainingOrg] = await tx
    .select({ stripeSubscriptionId: users.stripeSubscriptionId })
    .from(organizationMembers)
    .innerJoin(organizations, eq(organizationMembers.organizationId, organizations.id))
    .innerJoin(users, eq(organizations.ownerId, users.id))
    .where(
      and(
        eq(organizationMembers.userId, member.userId),
        eq(organizationMembers.hasProSeat, true),
      ),
    )
    .limit(1);
  if (remainingOrg?.stripeSubscriptionId) {
    await tx
      .update(users)
      .set({ thirdPartyStripeSubscriptionId: remainingOrg.stripeSubscriptionId })
      .where(eq(users.id, member.userId));
  }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/toggle-pro-seat.ts
Line: 102-125

Comment:
**Stale `thirdPartyStripeSubscriptionId` when user holds Pro seats across multiple orgs**

When revoking a Pro seat, the query on lines 108–117 checks for any other `hasProSeat = true` record for the same `userId` **without filtering by organization**. If the user belongs to Org A (subscription X) and Org B (subscription Y), and you revoke their seat in Org A, the query finds their Org B seat and skips the subscription ID update — leaving `thirdPartyStripeSubscriptionId` unchanged, still pointing to Org A's subscription X. The user retains a subscription ID that no longer applies in the current organization context.

Update the code to set the correct subscription when other Pro seats exist:

```ts
if (otherProSeats.length === 0) {
  await tx
    .update(users)
    .set({ thirdPartyStripeSubscriptionId: null })
    .where(eq(users.id, member.userId));
} else {
  // Update to the subscription of a remaining Pro-seat org
  const [remainingOrg] = await tx
    .select({ stripeSubscriptionId: users.stripeSubscriptionId })
    .from(organizationMembers)
    .innerJoin(organizations, eq(organizationMembers.organizationId, organizations.id))
    .innerJoin(users, eq(organizations.ownerId, users.id))
    .where(
      and(
        eq(organizationMembers.userId, member.userId),
        eq(organizationMembers.hasProSeat, true),
      ),
    )
    .limit(1);
  if (remainingOrg?.stripeSubscriptionId) {
    await tx
      .update(users)
      .set({ thirdPartyStripeSubscriptionId: remainingOrg.stripeSubscriptionId })
      .where(eq(users.id, member.userId));
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Member Author

@greptileai please review again

size="sm"
variant="primary"
onClick={() => updateMutation.mutate()}
disabled={
Copy link

Choose a reason for hiding this comment

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

While the debounce timer is still running (desiredQuantity !== debouncedQuantity), previewLoading is false and the Confirm button becomes clickable before the preview calculation has started.

Suggested change
disabled={
disabled={
updateMutation.isPending ||
previewLoading ||
previewError ||
desiredQuantity !== debouncedQuantity
}

)
.limit(1);

if (remainingOrg?.stripeSubscriptionId) {
Copy link

Choose a reason for hiding this comment

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

If otherProSeats exists but no remaining org has a stripeSubscriptionId, this leaves thirdPartyStripeSubscriptionId unchanged (potentially still pointing at the just-revoked org). Safer to explicitly clear it.

Suggested change
if (remainingOrg?.stripeSubscriptionId) {
if (remainingOrg?.stripeSubscriptionId) {
await tx
.update(users)
.set({
thirdPartyStripeSubscriptionId: remainingOrg.stripeSubscriptionId,
})
.where(eq(users.id, member.userId));
} else {
await tx
.update(users)
.set({ thirdPartyStripeSubscriptionId: null })
.where(eq(users.id, member.userId));
}

Comment on lines +54 to +84
const updateDesiredQuantity = (newQuantity: number) => {
setDesiredQuantity(newQuantity);
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
setDebouncedQuantity(newQuantity);
}, DEBOUNCE_MS);
};

const hasChanges = desiredQuantity !== proSeatsTotal;
const debouncedHasChanges = debouncedQuantity !== proSeatsTotal;

const {
data: preview,
isFetching: previewLoading,
isError: previewError,
} = useQuery({
queryKey: ["seat-preview", organizationId, debouncedQuantity],
queryFn: () => {
if (!organizationId) return null;
return previewSeatChange(organizationId, debouncedQuantity);
},
enabled: !!organizationId && debouncedHasChanges,
staleTime: 30 * 1000,
});

const updateMutation = useMutation({
mutationFn: () => {
if (!organizationId) throw new Error("No organization");
return updateSeatQuantity(organizationId, desiredQuantity);
Copy link
Contributor

Choose a reason for hiding this comment

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

Preview/confirm quantity mismatch during debounce window — The preview query uses debouncedQuantity (line 72), but the update mutation sends desiredQuantity (line 84). The Confirm button disables only when previewLoading || previewError (line 171), not when the debounce is in-flight. If a user rapidly clicks + and then "Confirm" within the 500 ms debounce window, the preview reflects the old debounced value while Stripe is charged for the current desiredQuantity — potentially differing by one or more seats.

Prevent confirming a stale preview by disabling the Confirm button when desiredQuantity !== debouncedQuantity:

const debounceInFlight = desiredQuantity !== debouncedQuantity;

<Button
  type="button"
  size="sm"
  variant="primary"
  onClick={() => updateMutation.mutate()}
  disabled={
    updateMutation.isPending || 
    previewLoading || 
    previewError || 
    debounceInFlight  // ← prevent confirming while debounce is pending
  }
  spinner={updateMutation.isPending}
>
  {updateMutation.isPending ? "Updating..." : "Confirm"}
</Button>
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx
Line: 54-84

Comment:
**Preview/confirm quantity mismatch during debounce window** — The preview query uses `debouncedQuantity` (line 72), but the update mutation sends `desiredQuantity` (line 84). The Confirm button disables only when `previewLoading || previewError` (line 171), not when the debounce is in-flight. If a user rapidly clicks `+` and then "Confirm" within the 500 ms debounce window, the preview reflects the old debounced value while Stripe is charged for the current `desiredQuantity` — potentially differing by one or more seats.

Prevent confirming a stale preview by disabling the Confirm button when `desiredQuantity !== debouncedQuantity`:

```ts
const debounceInFlight = desiredQuantity !== debouncedQuantity;

<Button
  type="button"
  size="sm"
  variant="primary"
  onClick={() => updateMutation.mutate()}
  disabled={
    updateMutation.isPending || 
    previewLoading || 
    previewError || 
    debounceInFlight  // ← prevent confirming while debounce is pending
  }
  spinner={updateMutation.isPending}
>
  {updateMutation.isPending ? "Updating..." : "Confirm"}
</Button>
```

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Additional Comments (1)

apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx
Silent success on invalid or failed inviteshandleAddEmails sends all emails to the server without client-side format validation, and the onSuccess callback ignores the failedEmails returned by sendOrganizationInvites. When a user enters invalid addresses (e.g., "notanemail"), they are sent to the server, filtered out by server-side regex, and the user sees "Invites sent successfully" despite no invites actually being created.

Add client-side email validation before adding to the list:

const handleAddEmails = () => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  const newEmails = emailInput
    .split(",")
    .map((email) => email.trim())
    .filter((email) => email !== "");
  
  const invalidEmails = newEmails.filter((e) => !emailRegex.test(e));
  if (invalidEmails.length > 0) {
    toast.error(`Invalid email address(es): ${invalidEmails.join(", ")}`);
  }
  const validNewEmails = newEmails.filter((e) => emailRegex.test(e));
  setInviteEmails([...new Set([...inviteEmails, ...validNewEmails])]);
  setEmailInput("");
};

And use the failedEmails return value in onSuccess to surface partial failures:

onSuccess: (result) => {
  if (result.failedEmails.length > 0) {
    toast.warning(
      `Invites sent, but delivery failed for: ${result.failedEmails.join(", ")}`
    );
  } else {
    toast.success("Invites sent successfully");
  }
  setInviteEmails([]);
  setIsOpen(false);
  router.refresh();
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx
Line: 33-62

Comment:
**Silent success on invalid or failed invites**`handleAddEmails` sends all emails to the server without client-side format validation, and the `onSuccess` callback ignores the `failedEmails` returned by `sendOrganizationInvites`. When a user enters invalid addresses (e.g., "notanemail"), they are sent to the server, filtered out by server-side regex, and the user sees "Invites sent successfully" despite no invites actually being created.

Add client-side email validation before adding to the list:

```ts
const handleAddEmails = () => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  const newEmails = emailInput
    .split(",")
    .map((email) => email.trim())
    .filter((email) => email !== "");
  
  const invalidEmails = newEmails.filter((e) => !emailRegex.test(e));
  if (invalidEmails.length > 0) {
    toast.error(`Invalid email address(es): ${invalidEmails.join(", ")}`);
  }
  const validNewEmails = newEmails.filter((e) => emailRegex.test(e));
  setInviteEmails([...new Set([...inviteEmails, ...validNewEmails])]);
  setEmailInput("");
};
```

And use the `failedEmails` return value in `onSuccess` to surface partial failures:

```ts
onSuccess: (result) => {
  if (result.failedEmails.length > 0) {
    toast.warning(
      `Invites sent, but delivery failed for: ${result.failedEmails.join(", ")}`
    );
  } else {
    toast.success("Invites sent successfully");
  }
  setInviteEmails([]);
  setIsOpen(false);
  router.refresh();
},
```

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Member Author

@greptileai please review again from scratch. remember, in this PR we are making it so that free users can be invited to an org... alongside being able to still purchase pro seats.

}
}

revalidatePath("/dashboard/settings/organization");
Copy link

Choose a reason for hiding this comment

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

Since settings now live under /dashboard/settings/organization/*, this won’t invalidate the tab routes. Revalidating the layout should cover /billing, /preferences, etc.

Suggested change
revalidatePath("/dashboard/settings/organization");
revalidatePath("/dashboard/settings/organization", "layout");

Comment on lines +114 to +119
<p>
${subscription.pricePerSeat.toFixed(2)}/seat/mo (
{subscription.currentQuantity}{" "}
{subscription.currentQuantity === 1 ? "seat" : "seats"} = $
{totalAmount.toFixed(2)}/mo, billed {intervalLabel})
</p>
Copy link

Choose a reason for hiding this comment

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

subscription.currency is available but the UI hardcodes $ here, which will read wrong for non-USD subscriptions.

Suggested change
<p>
${subscription.pricePerSeat.toFixed(2)}/seat/mo (
{subscription.currentQuantity}{" "}
{subscription.currentQuantity === 1 ? "seat" : "seats"} = $
{totalAmount.toFixed(2)}/mo, billed {intervalLabel})
</p>
<p>
{subscription.currency.toUpperCase()} {subscription.pricePerSeat.toFixed(2)}/seat/mo (
{subscription.currentQuantity}{" "}
{subscription.currentQuantity === 1 ? "seat" : "seats"} ={" "}
{subscription.currency.toUpperCase()} {totalAmount.toFixed(2)}/mo, billed {intervalLabel})
</p>

});

const currentQuantity = subscriptionItem.quantity ?? 1;
const proratedAmount = preview.amount_due;
Copy link
Contributor

Choose a reason for hiding this comment

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

preview.amount_due reflects the full upcoming invoice total, not the incremental proration cost. When stripe().invoices.retrieveUpcoming() is called with prorated items, amount_due includes both the base subscription renewal charge for the next period and any mid-cycle proration adjustments. The UI label on line 162 of SeatManagementCard.tsx ("Prorated charge: $X") therefore misleads users into thinking the full invoice amount is the seat-change cost.

To isolate the actual proration amount, filter the preview invoice line items:

const proratedAmount = preview.lines.data
  .filter((line) => line.proration)
  .reduce((sum, line) => sum + line.amount, 0);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/update-seat-quantity.ts
Line: 112

Comment:
`preview.amount_due` reflects the **full upcoming invoice total**, not the incremental proration cost. When `stripe().invoices.retrieveUpcoming()` is called with prorated items, `amount_due` includes both the base subscription renewal charge for the next period **and** any mid-cycle proration adjustments. The UI label on line 162 of `SeatManagementCard.tsx` ("Prorated charge: $X") therefore misleads users into thinking the full invoice amount is the seat-change cost.

To isolate the actual proration amount, filter the preview invoice line items:

```ts
const proratedAmount = preview.lines.data
  .filter((line) => line.proration)
  .reduce((sum, line) => sum + line.amount, 0);
```

How can I resolve this? If you propose a fix, please make it concise.

href: "/dashboard/settings/organization/preferences",
},
{
label: "Billing & Members",
Copy link
Contributor

Choose a reason for hiding this comment

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

The "Billing & Members" tab label (line 15) is rendered unconditionally, but in billing/page.tsx the BillingSummaryCard and SeatManagementCard are only shown when buildEnv.NEXT_PUBLIC_IS_CAP is true. On self-hosted instances, users will navigate to a tab labeled "Billing & Members" and only see the Members section — the "Billing" part is misleading.

Consider conditionally adjusting the tab label:

{
  label: buildEnv.NEXT_PUBLIC_IS_CAP ? "Billing & Members" : "Members",
  href: "/dashboard/settings/organization/billing",
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/_components/SettingsNav.tsx
Line: 15

Comment:
The "Billing & Members" tab label (line 15) is rendered unconditionally, but in `billing/page.tsx` the `BillingSummaryCard` and `SeatManagementCard` are only shown when `buildEnv.NEXT_PUBLIC_IS_CAP` is true. On self-hosted instances, users will navigate to a tab labeled "Billing & Members" and only see the Members section — the "Billing" part is misleading.

Consider conditionally adjusting the tab label:

```ts
{
  label: buildEnv.NEXT_PUBLIC_IS_CAP ? "Billing & Members" : "Members",
  href: "/dashboard/settings/organization/billing",
},
```

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 3, 2026

Additional Comments (1)

apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx
When a user adds email addresses and closes the dialog (via Escape, clicking outside, or "Cancel") without submitting, the inviteEmails and emailInput state persist. The next time the dialog is opened, the stale email list is still visible — a minor UX regression where users must manually clear previous entries.

Add a reset handler to the onOpenChange callback on the Dialog:

<Dialog
  open={isOpen}
  onOpenChange={(open) => {
    if (!open) {
      setInviteEmails([]);
      setEmailInput("");
    }
    setIsOpen(open);
  }}
>
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx
Line: 89

Comment:
When a user adds email addresses and closes the dialog (via Escape, clicking outside, or "Cancel") without submitting, the `inviteEmails` and `emailInput` state persist. The next time the dialog is opened, the stale email list is still visible — a minor UX regression where users must manually clear previous entries.

Add a reset handler to the `onOpenChange` callback on the `Dialog`:

```tsx
<Dialog
  open={isOpen}
  onOpenChange={(open) => {
    if (!open) {
      setInviteEmails([]);
      setEmailInput("");
    }
    setIsOpen(open);
  }}
>
```

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Member Author

@greptileai please review again from scratch. remember, in this PR we are making it so that free users can be invited to an org... alongside being able to still purchase pro seats.

: preview.lines.data;

const currentQuantity = subscriptionItem.quantity ?? 1;
const proratedAmount = previewLines.reduce((total, line) => {
Copy link

Choose a reason for hiding this comment

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

If this preview is meant to reflect what the customer will actually be charged/credited, summing only line.amount may undercount when automatic tax is enabled (tax is in tax_amounts).

Suggested change
const proratedAmount = previewLines.reduce((total, line) => {
const proratedAmount = previewLines.reduce((total, line) => {
if (!line.proration) return total;
const taxAmount =
line.tax_amounts?.reduce((sum, t) => sum + t.amount, 0) ?? 0;
return total + line.amount + taxAmount;
}, 0);

<span className="text-sm text-gray-11">
{preview.proratedAmount === 0
? "No prorated adjustment"
: `${preview.proratedAmount > 0 ? "Prorated charge" : "Prorated credit"}: $${Math.abs(preview.proratedAmount / 100).toFixed(2)} ${preview.currency.toUpperCase()}`}
Copy link

Choose a reason for hiding this comment

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

This still hardcodes $, so non-USD proration will read wrong. Using Intl.NumberFormat keeps the symbol/code consistent with preview.currency.

Suggested change
: `${preview.proratedAmount > 0 ? "Prorated charge" : "Prorated credit"}: $${Math.abs(preview.proratedAmount / 100).toFixed(2)} ${preview.currency.toUpperCase()}`}
{preview.proratedAmount === 0
? "No prorated adjustment"
: `${preview.proratedAmount > 0 ? "Prorated charge" : "Prorated credit"}: ${new Intl.NumberFormat(undefined, { style: "currency", currency: preview.currency, currencyDisplay: "code" }).format(Math.abs(preview.proratedAmount) / 100)}`}

Comment on lines +44 to 57
.map((email) => email.trim().toLowerCase())
.filter((email) => email !== "");

if (inviteEmails.length + newEmails.length > remainingSeats) {
const invalidEmails = newEmails.filter((email) => !emailRegex.test(email));
if (invalidEmails.length > 0) {
toast.error(
`Not enough seats available. You have ${remainingSeats} seats remaining.`,
`Invalid email${invalidEmails.length > 1 ? "s" : ""}: ${invalidEmails.join(", ")}`,
);
return;
}

setInviteEmails([...new Set([...inviteEmails, ...newEmails])]);
const validEmails = newEmails.filter((email) => emailRegex.test(email));
setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
setEmailInput("");
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Email input cleared even when all emails are invalid

setEmailInput("") is called unconditionally at the end of handleAddEmails, even when every email the user typed was invalid. If a user blurs the field with only invalid addresses, the error toast fires but their text is also wiped — they must retype from scratch to correct it.

The fix is to only clear the input when at least one valid email was actually queued:

Suggested change
.map((email) => email.trim().toLowerCase())
.filter((email) => email !== "");
if (inviteEmails.length + newEmails.length > remainingSeats) {
const invalidEmails = newEmails.filter((email) => !emailRegex.test(email));
if (invalidEmails.length > 0) {
toast.error(
`Not enough seats available. You have ${remainingSeats} seats remaining.`,
`Invalid email${invalidEmails.length > 1 ? "s" : ""}: ${invalidEmails.join(", ")}`,
);
return;
}
setInviteEmails([...new Set([...inviteEmails, ...newEmails])]);
const validEmails = newEmails.filter((email) => emailRegex.test(email));
setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
setEmailInput("");
};
const handleAddEmails = () => {
const newEmails = emailInput
.split(",")
.map((email) => email.trim().toLowerCase())
.filter((email) => email !== "");
const invalidEmails = newEmails.filter((email) => !emailRegex.test(email));
if (invalidEmails.length > 0) {
toast.error(
`Invalid email${invalidEmails.length > 1 ? "s" : ""}: ${invalidEmails.join(", ")}`,
);
}
const validEmails = newEmails.filter((email) => emailRegex.test(email));
if (validEmails.length > 0) {
setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
setEmailInput("");
}
};

This preserves the typed text in the input so the user can fix their typos, while still surfacing the validation error.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx
Line: 44-57

Comment:
**Email input cleared even when all emails are invalid**

`setEmailInput("")` is called unconditionally at the end of `handleAddEmails`, even when every email the user typed was invalid. If a user blurs the field with only invalid addresses, the error toast fires but their text is also wiped — they must retype from scratch to correct it.

The fix is to only clear the input when at least one valid email was actually queued:

```suggestion
	const handleAddEmails = () => {
		const newEmails = emailInput
			.split(",")
			.map((email) => email.trim().toLowerCase())
			.filter((email) => email !== "");

		const invalidEmails = newEmails.filter((email) => !emailRegex.test(email));
		if (invalidEmails.length > 0) {
			toast.error(
				`Invalid email${invalidEmails.length > 1 ? "s" : ""}: ${invalidEmails.join(", ")}`,
			);
		}

		const validEmails = newEmails.filter((email) => emailRegex.test(email));
		if (validEmails.length > 0) {
			setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
			setEmailInput("");
		}
	};
```

This preserves the typed text in the input so the user can fix their typos, while still surfacing the validation error.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +44 to 57
.map((email) => email.trim().toLowerCase())
.filter((email) => email !== "");

if (inviteEmails.length + newEmails.length > remainingSeats) {
const invalidEmails = newEmails.filter((email) => !emailRegex.test(email));
if (invalidEmails.length > 0) {
toast.error(
`Not enough seats available. You have ${remainingSeats} seats remaining.`,
`Invalid email${invalidEmails.length > 1 ? "s" : ""}: ${invalidEmails.join(", ")}`,
);
return;
}

setInviteEmails([...new Set([...inviteEmails, ...newEmails])]);
const validEmails = newEmails.filter((email) => emailRegex.test(email));
setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
setEmailInput("");
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Ambiguous feedback when mixing valid and invalid emails

When a user types "valid@email.com, bad-email" and triggers handleAddEmails, two things happen simultaneously:

  1. An error toast fires — "Invalid email: bad-email"
  2. valid@email.com is silently queued in the invite list and the input is cleared

The user sees an error and may believe the entire operation failed, not realising that valid@email.com was actually added. Consider adding a distinct success toast for the partial case:

Suggested change
.map((email) => email.trim().toLowerCase())
.filter((email) => email !== "");
if (inviteEmails.length + newEmails.length > remainingSeats) {
const invalidEmails = newEmails.filter((email) => !emailRegex.test(email));
if (invalidEmails.length > 0) {
toast.error(
`Not enough seats available. You have ${remainingSeats} seats remaining.`,
`Invalid email${invalidEmails.length > 1 ? "s" : ""}: ${invalidEmails.join(", ")}`,
);
return;
}
setInviteEmails([...new Set([...inviteEmails, ...newEmails])]);
const validEmails = newEmails.filter((email) => emailRegex.test(email));
setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
setEmailInput("");
};
const validEmails = newEmails.filter((email) => emailRegex.test(email));
if (validEmails.length > 0) {
setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
setEmailInput("");
if (invalidEmails.length > 0) {
toast.success(`Added ${validEmails.length} email(s). See error above for invalid addresses.`);
}
}

This makes it explicit that some emails were queued successfully despite the validation warning.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/settings/organization/components/InviteDialog.tsx
Line: 44-57

Comment:
**Ambiguous feedback when mixing valid and invalid emails**

When a user types `"valid@email.com, bad-email"` and triggers `handleAddEmails`, two things happen simultaneously:
1. An error toast fires — *"Invalid email: bad-email"*
2. `valid@email.com` is silently queued in the invite list and the input is cleared

The user sees an error and may believe the entire operation failed, not realising that `valid@email.com` was actually added. Consider adding a distinct success toast for the partial case:

```suggestion
		const validEmails = newEmails.filter((email) => emailRegex.test(email));
		if (validEmails.length > 0) {
			setInviteEmails([...new Set([...inviteEmails, ...validEmails])]);
			setEmailInput("");
			if (invalidEmails.length > 0) {
				toast.success(`Added ${validEmails.length} email(s). See error above for invalid addresses.`);
			}
		}
```

This makes it explicit that some emails were queued successfully despite the validation warning.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@richiemcilroy richiemcilroy merged commit ea474d4 into main Mar 3, 2026
15 of 17 checks passed
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