Skip to content

Commit 4bea3f8

Browse files
authored
fix(auth): grant billing_manager same UI and backend permissions as owner (#1084)
<img width="1243" height="776" alt="image" src="https://github.com/user-attachments/assets/2e5b557b-ffc2-403e-bde5-996ba12e582a" /> <img width="1397" height="541" alt="image" src="https://github.com/user-attachments/assets/f76c83ff-7c2b-4529-99aa-81ad1503ddbe" /> ## Summary - The backend `organizationOwnerProcedure` (in `src/routers/organizations/utils.ts:127`) already grants `billing_manager` the same access as `owner`, but multiple frontend permission checks and one backend route only checked for `currentUserRole === 'owner'`, effectively hiding actions from billing managers that the API would allow. - This PR aligns all frontend permission gates and one backend access check with the intended `organizationOwnerProcedure` behavior. **Frontend fixes (4 files):** - `OrganizationMembersCard.tsx`: `canRemoveMember`, `canEditRole`, `canEditLimit`, `canDelete` (invitations), `canCopy` (invite URL) now include `billing_manager` via a shared `isPrivilegedRole` helper - `SeatUsageCard.tsx`: "Manage" subscription link now visible to `billing_manager` - `OrganizationDataCollectionCard.tsx`: `canEdit` now includes `billing_manager`; corrected help text from "admins" to "billing managers" - `InviteMemberDialog.tsx`: removed stale `admin` entry from `ROLE_LABELS` (not a valid `OrganizationRole`) **Backend fix (1 file):** - `organization-subscription-router.ts`: `getSubscriptionStripeUrl` now allows `['owner', 'billing_manager']` when prior subscriptions exist, aligning with all other subscription routes that use `organizationOwnerProcedure` ## Verification - [x] `pnpm typecheck` — all 29 workspace projects pass with zero errors - [x] Manual review of every `organizationOwnerProcedure` usage to confirm backend already allows `billing_manager` - [x] Verified no other frontend `currentUserRole === 'owner'` checks exist that should include `billing_manager` (remaining owner-only checks are for SSO config and admin-only routes, which are intentionally restricted) ## Visual Changes N/A ## Reviewer Notes - The `isPrivilegedRole` helper in `OrganizationMembersCard.tsx` centralizes the `owner || billing_manager` check to avoid repeating the pattern. It's intentionally scoped to that file since other files only have 1-2 checks. - SSO routes (`organization-sso-router.ts`) and admin-gated routes (`organization-router.ts` `updateSeatsRequired`/`seatPurchases`) remain `['owner']`-only, as they also require `adminProcedure` and are Kilo-admin operations. - The `cloudflare-ai-attribution/src/schemas.ts` zod enum still omits `billing_manager` — this is a separate service and was left out of scope, but is a known inconsistency worth tracking.
2 parents ae92745 + bdc62fc commit 4bea3f8

5 files changed

Lines changed: 20 additions & 16 deletions

File tree

src/components/organizations/OrganizationDataCollectionCard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ export function OrganizationDataCollectionCard({
7373
const currentDataCollection = organizationData.settings?.data_collection ?? null;
7474
const displayValue = currentDataCollection === null ? 'extension' : currentDataCollection;
7575

76-
const canEdit = currentUserRole === 'owner' && !isReadOnly;
76+
const canEdit =
77+
(currentUserRole === 'owner' || currentUserRole === 'billing_manager') && !isReadOnly;
7778

7879
return (
7980
<Card>
@@ -134,7 +135,7 @@ export function OrganizationDataCollectionCard({
134135

135136
{!canEdit && (
136137
<p className="text-muted-foreground text-xs">
137-
Only organization owners and admins can modify data collection settings.
138+
Only organization owners and billing managers can modify data collection settings.
138139
</p>
139140
)}
140141
</CardContent>

src/components/organizations/OrganizationMembersCard.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,12 @@ function DailyUsageLimitDisplay({ member }: DailyUsageLimitDisplayProps) {
7070
return null;
7171
}
7272

73+
const isPrivilegedRole = (role: OrganizationRole): boolean =>
74+
role === 'owner' || role === 'billing_manager';
75+
7376
// Business rules for member removal:
7477
// - Kilo admins can remove anyone
75-
// - Owners can remove anyone except themselves
78+
// - Owners and billing managers can remove anyone except themselves
7679
// - Members cannot remove anyone
7780
const canRemoveMember = (
7881
currentUserRole: OrganizationRole,
@@ -86,8 +89,8 @@ const canRemoveMember = (
8689
// Cannot remove yourself
8790
if (isCurrentUser) return false;
8891

89-
// Owners can remove anyone (except themselves)
90-
if (currentUserRole === 'owner') return true;
92+
// Owners and billing managers can remove anyone (except themselves)
93+
if (isPrivilegedRole(currentUserRole)) return true;
9194

9295
// Members cannot remove anyone
9396
return false;
@@ -150,9 +153,9 @@ function DeleteInvitationButton({ organizationId, member }: DeleteInvitationButt
150153
}
151154

152155
// Apply same role restrictions as invitation creation
153-
// - Owners can delete any invitation
156+
// - Owners and billing managers can delete any invitation
154157
// - Members cannot delete invitations
155-
const canDelete = isKiloAdmin || currentUserRole === 'owner';
158+
const canDelete = isKiloAdmin || isPrivilegedRole(currentUserRole);
156159

157160
if (!canDelete) {
158161
return null;
@@ -189,7 +192,7 @@ function InvitedBadge({ member }: InvitedBadgeProps) {
189192
return null;
190193
}
191194

192-
const canCopy = isKiloAdmin || currentUserRole === 'owner';
195+
const canCopy = isKiloAdmin || isPrivilegedRole(currentUserRole);
193196

194197
const handleCopy = async (e: React.MouseEvent) => {
195198
e.preventDefault();
@@ -242,8 +245,9 @@ function EditLimitButton({ organization, member }: EditLimitButtonProps) {
242245
return null;
243246
}
244247

245-
// Only show edit limit button for active members and if user has permission (admin/owner only)
246-
const canEditLimit = member.status === 'active' && (isKiloAdmin || currentUserRole === 'owner');
248+
// Only show edit limit button for active members and if user has permission (admin/owner/billing_manager)
249+
const canEditLimit =
250+
member.status === 'active' && (isKiloAdmin || isPrivilegedRole(currentUserRole));
247251

248252
if (!canEditLimit) {
249253
return null;
@@ -412,16 +416,16 @@ export function OrganizationAdminMembers({
412416
// Determine what actions are available for this member
413417
const canEditRole =
414418
member.status === 'active' &&
415-
(isKiloAdmin || currentUserRole === 'owner') &&
419+
(isKiloAdmin || isPrivilegedRole(currentUserRole)) &&
416420
(!isCurrentUser || isKiloAdmin);
417421
const canEditLimit =
418422
organizationData.plan === 'enterprise' &&
419423
member.status === 'active' &&
420-
(isKiloAdmin || currentUserRole === 'owner');
424+
(isKiloAdmin || isPrivilegedRole(currentUserRole));
421425
const canDelete =
422426
member.status === 'active'
423427
? canRemoveMember(currentUserRole, isKiloAdmin, member.role, isCurrentUser)
424-
: isKiloAdmin || currentUserRole === 'owner';
428+
: isKiloAdmin || isPrivilegedRole(currentUserRole);
425429

426430
const hasAnyEditableActions = canEditRole || canEditLimit || canDelete;
427431

src/components/organizations/SeatUsageCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export function SeatUsageCard({ organizationId }: Props) {
8282
<Users className="h-4 w-4" />
8383
<CardTitle>Seat Usage</CardTitle>
8484
</div>
85-
{currentUserRole === 'owner' && (
85+
{(currentUserRole === 'owner' || currentUserRole === 'billing_manager') && (
8686
<LinkButton
8787
href={`/organizations/${organizationId}/subscription`}
8888
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm transition-colors"

src/components/organizations/members/InviteMemberDialog.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ const emailSchema = z.email();
4040

4141
const ROLE_LABELS = {
4242
owner: 'Owner',
43-
admin: 'Admin',
4443
member: 'Member',
4544
billing_manager: 'Billing Manager',
4645
} as const;

src/routers/organizations/organization-subscription-router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export const organizationsSubscriptionRouter = createTRPCRouter({
143143
// if any subscriptions exist we need to enforce security
144144
// otherwise, we can't enforce ownership as the org is still not finished being set up
145145
if (subscriptions.length) {
146-
await ensureOrganizationAccess(ctx, organizationId, ['owner']);
146+
await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']);
147147
}
148148

149149
const result = await getStripeSeatsCheckoutUrl({

0 commit comments

Comments
 (0)