diff --git a/apps/mobile/src/components/organization/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx index 2a1fb449b0e..49bfe5ffb04 100644 --- a/apps/mobile/src/components/organization/member-row.tsx +++ b/apps/mobile/src/components/organization/member-row.tsx @@ -22,11 +22,12 @@ type MemberRowProps = { export const ROLE_LABEL: Record = { owner: 'Owner', + admin: 'Admin', member: 'Member', billing_manager: 'Billing manager', }; -const ROLE_OPTIONS: OrgRole[] = ['owner', 'member', 'billing_manager']; +const ROLE_OPTIONS: OrgRole[] = ['owner', 'admin', 'member', 'billing_manager']; export function MemberRow({ member, diff --git a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx index f062a3bb74e..0fa0e634670 100644 --- a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx +++ b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx @@ -97,6 +97,8 @@ export function OrganizationSwitcherView({ switch (role) { case 'owner': return 'Owner'; + case 'admin': + return 'Admin'; case 'billing_manager': return 'Billing Manager'; case 'member': diff --git a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx index bb3f3890458..d5a19854bf4 100644 --- a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx @@ -1,4 +1,5 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { DistributeFundsPage } from './DistributeFundsPage'; export default async function OrganizationDistributeFundsPage({ @@ -9,7 +10,7 @@ export default async function OrganizationDistributeFundsPage({ return ( } /> ); diff --git a/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts b/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts index 0227b57cc4f..48c380bbb48 100644 --- a/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts +++ b/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; import { PLATFORM } from '@/lib/integrations/core/constants'; @@ -20,7 +21,7 @@ export async function GET(request: NextRequest, context: RouteContext) { } try { - await ensureOrganizationAccess({ user }, id, ['owner', 'billing_manager']); + await ensureOrganizationAccess({ user }, id, ORGANIZATION_BILLING_ROLES); await requireActiveSubscriptionOrTrial(id); } catch { return NextResponse.redirect(new URL('/integrations?error=unauthorized', request.url)); diff --git a/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx b/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx index a2ba940348b..322f498d08e 100644 --- a/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx @@ -1,6 +1,7 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; import { OrgKiloPassBillingHistory } from '@/components/subscriptions/org-kilo-pass/OrgKiloPassBillingHistory'; import { OrgKiloPassDetail } from '@/components/subscriptions/org-kilo-pass/OrgKiloPassDetail'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; export default async function OrganizationKiloPassPage({ params, @@ -10,7 +11,7 @@ export default async function OrganizationKiloPassPage({ return ( (
( )} diff --git a/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx b/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx index d45cb8d34d5..7ff3e6d1cfb 100644 --- a/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx @@ -1,5 +1,6 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; import { OrgSubscriptions } from '@/components/subscriptions/OrgSubscriptions'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; export default async function OrganizationSubscriptionsPage({ params, @@ -9,7 +10,7 @@ export default async function OrganizationSubscriptionsPage({ return ( } /> ); diff --git a/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx b/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx index 214a52a0e65..2b803f7805c 100644 --- a/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx @@ -1,5 +1,6 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; import { SeatsDetail } from '@/components/subscriptions/seats/SeatsDetail'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; export default async function OrganizationSeatsSubscriptionPage({ params, @@ -9,7 +10,7 @@ export default async function OrganizationSeatsSubscriptionPage({ return ( } /> ); diff --git a/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx index af172f98a5c..aa9937dba4d 100644 --- a/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx @@ -1,6 +1,7 @@ import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; import { signInUrlWithCallbackPath } from '@/lib/user/server'; import { OrganizationContextProvider } from '@/components/organizations/OrganizationContext'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { redirect } from 'next/navigation'; export default async function WelcomeLayout({ @@ -12,7 +13,7 @@ export default async function WelcomeLayout({ }) { const { id } = await params; const organizationId = decodeURIComponent(id); - const result = await getAuthorizedOrgContext(organizationId, ['owner', 'billing_manager']); + const result = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES); if (!result.success) { const href = result.nextResponse.status === 401 ? await signInUrlWithCallbackPath() : '/profile'; diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx b/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx index 37e0430cae0..f73cab16456 100644 --- a/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx +++ b/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx @@ -209,6 +209,7 @@ export function AddMemberDialog({ Member Owner + Admin Billing Manager diff --git a/apps/web/src/app/api/auto-routing/mode/route.test.ts b/apps/web/src/app/api/auto-routing/mode/route.test.ts index 861c54598f3..1d19fa39d3b 100644 --- a/apps/web/src/app/api/auto-routing/mode/route.test.ts +++ b/apps/web/src/app/api/auto-routing/mode/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import { NextRequest } from 'next/server'; import { @@ -93,7 +94,7 @@ describe('/api/auto-routing/mode', () => { user: expect.objectContaining({ id: USER_ID, is_admin: false }), }), ORGANIZATION_ID, - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); expect(mockedRequireActiveSubscriptionOrTrial).toHaveBeenCalledWith(ORGANIZATION_ID); expect(mockedUpdateAutoRoutingMode).toHaveBeenCalledWith({ diff --git a/apps/web/src/app/api/auto-routing/mode/route.ts b/apps/web/src/app/api/auto-routing/mode/route.ts index ef3baf512c0..56fd04cb80b 100644 --- a/apps/web/src/app/api/auto-routing/mode/route.ts +++ b/apps/web/src/app/api/auto-routing/mode/route.ts @@ -10,6 +10,7 @@ import { updateAutoRoutingMode, } from '@/lib/ai-gateway/auto-routing-admin-client'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; @@ -71,7 +72,7 @@ export async function GET(request: NextRequest): Promise { } export async function PUT(request: NextRequest): Promise { - const owner = await resolveOwner(request, ['owner', 'billing_manager']); + const owner = await resolveOwner(request, ORGANIZATION_BILLING_ROLES); if ('response' in owner) return owner.response; if (owner.ownerType === 'org') { try { diff --git a/apps/web/src/app/api/auto-routing/settings/route.test.ts b/apps/web/src/app/api/auto-routing/settings/route.test.ts index e3516e8e977..489aa055ee9 100644 --- a/apps/web/src/app/api/auto-routing/settings/route.test.ts +++ b/apps/web/src/app/api/auto-routing/settings/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import type { AutoRoutingSettingsResponse } from '@kilocode/auto-routing-contracts'; import { NextRequest } from 'next/server'; @@ -262,7 +263,7 @@ describe('/api/auto-routing/settings', () => { expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( expect.anything(), ORGANIZATION_ID, - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); }); diff --git a/apps/web/src/app/api/auto-routing/settings/route.ts b/apps/web/src/app/api/auto-routing/settings/route.ts index 318524b6057..40d0f3f1520 100644 --- a/apps/web/src/app/api/auto-routing/settings/route.ts +++ b/apps/web/src/app/api/auto-routing/settings/route.ts @@ -22,6 +22,7 @@ import { type AutoRoutingSettingsApiResponse, } from '@/lib/ai-gateway/auto-routing-pool-validation'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; @@ -156,7 +157,7 @@ export async function GET(request: NextRequest): Promise { } export async function PUT(request: NextRequest): Promise { - const owner = await resolveOwner(request, ['owner', 'billing_manager']); + const owner = await resolveOwner(request, ORGANIZATION_BILLING_ROLES); if ('response' in owner) return owner.response; if (owner.ownerType === 'org') { try { diff --git a/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts b/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts index dd9722b148b..810bb791ef6 100644 --- a/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts +++ b/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { NextRequest } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; import { verifyOAuthState } from '@/lib/integrations/oauth-state'; @@ -66,7 +67,7 @@ describe('GET /api/integrations/bitbucket/connect', () => { expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( { user: expect.objectContaining({ id: USER_ID }) }, ORGANIZATION_ID, - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); }); }); diff --git a/apps/web/src/app/api/integrations/linear/connect/route.test.ts b/apps/web/src/app/api/integrations/linear/connect/route.test.ts index 0ea2c4f6a0c..eac653f2df8 100644 --- a/apps/web/src/app/api/integrations/linear/connect/route.test.ts +++ b/apps/web/src/app/api/integrations/linear/connect/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { NextRequest } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; import { getLinearOAuthUrl } from '@/lib/integrations/linear-service'; @@ -93,7 +94,7 @@ describe('GET /api/integrations/linear/connect', () => { expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( { user: expect.objectContaining({ id: USER_ID }) }, 'org-linear-123', - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); expect(mockedRequireActiveSubscriptionOrTrial).toHaveBeenCalledWith('org-linear-123'); const state = mockedGetLinearOAuthUrl.mock.calls[0]?.[0]; diff --git a/apps/web/src/app/payments/topup/route.ts b/apps/web/src/app/payments/topup/route.ts index 6540150b70f..860038d69b1 100644 --- a/apps/web/src/app/payments/topup/route.ts +++ b/apps/web/src/app/payments/topup/route.ts @@ -7,6 +7,7 @@ import { isValidReturnUrl } from '@/lib/payment-return-url'; import { captureException } from '@sentry/nextjs'; import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing'; import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; /** * NOTE: Crypto payment support (Coinbase Commerce) was removed in January 2026. @@ -66,7 +67,7 @@ export async function POST(request: NextRequest): Promise> let stripeCustomerId: string | null | undefined; if (organizationId) { - const orgContext = await getAuthorizedOrgContext(organizationId, ['owner', 'billing_manager']); + const orgContext = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES); if (!orgContext.success) { return orgContext.nextResponse; } diff --git a/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts b/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts index 59cf8a44a63..efadf05d1fc 100644 --- a/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts +++ b/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts @@ -973,9 +973,11 @@ describe('error messages', () => { describe('readonly org member controls', () => { it('billing-manager edit permission is isolated from page-level canEdit', () => { // Mirrors OrganizationProvidersAndModelsPage derivation. + const canManageOrganization = (role: string) => role === 'owner' || role === 'admin'; const derive = (role: string, isKiloAdmin = false) => { - const canEdit = isKiloAdmin || role === 'owner'; - const canEditAutoRouting = isKiloAdmin || role === 'owner' || role === 'billing_manager'; + const canEdit = isKiloAdmin || canManageOrganization(role); + const canEditAutoRouting = + isKiloAdmin || canManageOrganization(role) || role === 'billing_manager'; return { canEdit, canEditAutoRouting }; }; @@ -991,6 +993,10 @@ describe('readonly org member controls', () => { canEdit: true, canEditAutoRouting: true, }); + expect(derive('admin')).toEqual({ + canEdit: true, + canEditAutoRouting: true, + }); expect(derive('member', true)).toEqual({ canEdit: true, canEditAutoRouting: true, diff --git a/apps/web/src/components/organizations/BYOKContent.tsx b/apps/web/src/components/organizations/BYOKContent.tsx index 98645471f8c..d5f65c054b1 100644 --- a/apps/web/src/components/organizations/BYOKContent.tsx +++ b/apps/web/src/components/organizations/BYOKContent.tsx @@ -7,6 +7,7 @@ import { useRoleTesting } from '@/contexts/RoleTestingContext'; import { BYOKKeysManager } from './byok/BYOKKeysManager'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AlertCircle } from 'lucide-react'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; export function BYOKContent({ organizationId, @@ -21,8 +22,8 @@ export function BYOKContent({ const currentRole = assumedRole === 'KILO ADMIN' ? 'owner' : assumedRole || role || 'member'; const isKiloAdmin = assumedRole === 'KILO ADMIN'; - // Check if user has permission to access BYOK (must be org owner) - const hasPermission = currentRole === 'owner'; + // Check if user has permission to access BYOK (must be org owner or admin) + const hasPermission = canManageOrganization(currentRole); return ( diff --git a/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx b/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx index 98ae08df57c..556c05c5741 100644 --- a/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx +++ b/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx @@ -11,6 +11,7 @@ import type { OrganizationWithMembers, } from '@/lib/organizations/organization-types'; import { capitalize, cn } from '@/lib/utils'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; type FreeTrialWarningBannerProps = { organization: OrganizationWithMembers; @@ -125,11 +126,11 @@ export function FreeTrialWarningBanner({ const state = getOrgTrialStatusFromDays(daysRemaining); const planName = capitalize(organization.plan); const styles = getStylesForState(state, planName); - const isOwner = userRole === 'owner'; + const canManage = canManageOrganization(userRole); const buttonVariant = getButtonVariantForState(state); - const message = getTrialMessage(daysRemaining, isOwner); + const message = getTrialMessage(daysRemaining, canManage); const pathname = usePathname(); - const shouldShowUpgradeButton = isOwner && !pathname.endsWith('/subscriptions'); + const shouldShowUpgradeButton = canManage && !pathname.endsWith('/subscriptions'); return (
diff --git a/apps/web/src/components/organizations/OrganizationDashboard.tsx b/apps/web/src/components/organizations/OrganizationDashboard.tsx index 39bf4381479..b105aa3d2ea 100644 --- a/apps/web/src/components/organizations/OrganizationDashboard.tsx +++ b/apps/web/src/components/organizations/OrganizationDashboard.tsx @@ -13,7 +13,10 @@ import { OrganizationPageHeader } from './OrganizationPageHeader'; import { OrganizationWelcomeHeader } from './OrganizationWelcomeHeader'; import { NewOrganizationWelcomeHeader } from './NewOrganizationWelcomeHeader'; import { OrganizationTopupSuccessHeader } from './OrganizationTopupSuccessHeader'; -import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations'; +import { + canManageOrganization, + canManageOrganizationBilling, +} from '@kilocode/app-shared/organizations'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { useRoleTesting } from '@/contexts/RoleTestingContext'; import { useOrganizationWithMembers } from '@/app/api/organizations/hooks'; @@ -156,7 +159,7 @@ export function OrganizationDashboard({ ) : ( )} @@ -179,7 +182,7 @@ export function OrganizationDashboard({ )} - {(currentRole === 'owner' || isKiloAdmin) && ( + {(canManageOrganization(currentRole) || isKiloAdmin) && ( )} diff --git a/apps/web/src/components/organizations/OrganizationMembersCard.tsx b/apps/web/src/components/organizations/OrganizationMembersCard.tsx index 2d6dd551676..dff7d2fde1a 100644 --- a/apps/web/src/components/organizations/OrganizationMembersCard.tsx +++ b/apps/web/src/components/organizations/OrganizationMembersCard.tsx @@ -45,6 +45,10 @@ import { toast } from 'sonner'; import { useOrganizationReadOnly } from '@/lib/organizations/use-organization-read-only'; import { useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; +import { + canManageOrganization, + canManageOrganizationOwners, +} from '@kilocode/app-shared/organizations'; const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-US', { @@ -77,19 +81,32 @@ function DailyUsageLimitDisplay({ member }: DailyUsageLimitDisplayProps) { } const canManageMembers = (role: OrganizationRole, isKiloAdmin: boolean): boolean => - isKiloAdmin || role === 'owner'; + isKiloAdmin || canManageOrganization(role); const canInviteMembers = (role: OrganizationRole, isKiloAdmin: boolean): boolean => canManageMembers(role, isKiloAdmin) || role === 'billing_manager'; +/** + * True when the viewer may act on `targetRole`'s membership or invitation. + * Owner management is reserved for owners, so an admin must not be offered + * controls the server rejects with FORBIDDEN. + */ +const canActOnMemberRole = ( + currentUserRole: OrganizationRole, + isKiloAdmin: boolean, + targetRole: OrganizationRole +): boolean => isKiloAdmin || targetRole !== 'owner' || canManageOrganizationOwners(currentUserRole); + // Business rules for member removal: // - Kilo admins can remove anyone // - Owners can remove anyone except themselves -// - Members cannot remove anyone +// - Admins can remove anyone except themselves and owners +// - Billing managers and members cannot remove anyone const canRemoveMember = ( currentUserRole: OrganizationRole, isKiloAdmin: boolean, - isCurrentUser: boolean + isCurrentUser: boolean, + targetRole: OrganizationRole ): boolean => { // Kilo admin can remove anyone including themselves if (isKiloAdmin) return true; @@ -97,9 +114,11 @@ const canRemoveMember = ( // Cannot remove yourself if (isCurrentUser) return false; + if (!canActOnMemberRole(currentUserRole, isKiloAdmin, targetRole)) return false; + if (canManageMembers(currentUserRole, isKiloAdmin)) return true; - // Members cannot remove anyone + // Billing managers and members cannot remove anyone return false; }; @@ -121,7 +140,7 @@ function DeleteMemberButton({ organizationId, member }: DeleteMemberButtonProps) (member.status === 'active' && member.id === kiloUserId) || member.email === kiloUserEmail; // Only show delete button for active members and if user has permission to remove - const canRemove = canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser); + const canRemove = canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser, member.role); if (member.status !== 'active' || !canRemove) { return null; @@ -159,7 +178,10 @@ function DeleteInvitationButton({ organizationId, member }: DeleteInvitationButt return null; } - const canDelete = canManageMembers(currentUserRole, isKiloAdmin); + // An admin cannot revoke a pending owner invitation, so don't offer the control. + const canDelete = + canManageMembers(currentUserRole, isKiloAdmin) && + canActOnMemberRole(currentUserRole, isKiloAdmin, member.role); if (!canDelete) { return null; @@ -560,15 +582,20 @@ export function OrganizationAdminMembers({ const canEditRole = member.status === 'active' && canManageMembers(currentUserRole, isKiloAdmin) && + canActOnMemberRole(currentUserRole, isKiloAdmin, member.role) && (!isCurrentUser || isKiloAdmin); + // Usage limits are deliberately not owner-gated: the server only + // reserves role changes and removals for owners, so an admin may + // still set an owner's daily limit. const canEditLimit = organizationData.plan === 'enterprise' && member.status === 'active' && canManageMembers(currentUserRole, isKiloAdmin); const canDelete = member.status === 'active' - ? canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser) - : canManageMembers(currentUserRole, isKiloAdmin); + ? canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser, member.role) + : canManageMembers(currentUserRole, isKiloAdmin) && + canActOnMemberRole(currentUserRole, isKiloAdmin, member.role); const canEditChildTeams = member.status === 'active' && canInviteMembers(currentUserRole, isKiloAdmin); diff --git a/apps/web/src/components/organizations/SSOSignupCard.tsx b/apps/web/src/components/organizations/SSOSignupCard.tsx index 01d36bd949d..47db117ccb9 100644 --- a/apps/web/src/components/organizations/SSOSignupCard.tsx +++ b/apps/web/src/components/organizations/SSOSignupCard.tsx @@ -10,6 +10,7 @@ import type { OrganizationRole, OrganizationWithMembers, } from '@/lib/organizations/organization-types'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; type SSOSignupCardProps = { organization: OrganizationWithMembers; @@ -152,7 +153,7 @@ export function SSOSignupCard({ organization, role }: SSOSignupCardProps) { )}
{/* Verify Domain Link - only show to owners when domain is not verified */} - {!isDomainVerified && role === 'owner' && ( + {!isDomainVerified && canManageOrganization(role) && (
{/* Complete Setup Link - only show to owners when domain is verified */} - {isDomainVerified && role === 'owner' && ( + {isDomainVerified && canManageOrganization(role) && ( - {(['owner', 'member', 'billing_manager'] as const).map( + {(['owner', 'admin', 'member', 'billing_manager'] as const).map( (roleOption: OrganizationRole) => { const isAvailable = availableRoles.includes(roleOption); const isSelected = role === roleOption; diff --git a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx index 62a198aeb4f..1f12ec12696 100644 --- a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx +++ b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx @@ -31,15 +31,29 @@ const getRoleBadgeVariant = (role: string) => { const ROLE_LABELS = { owner: 'Owner', + admin: 'Admin', member: 'Member', billing_manager: 'Billing Manager', } as const; +const ASSIGNABLE_ROLES = ['owner', 'admin', 'member', 'billing_manager'] as const; + +/** + * Mirrors the server rules in organization-members-router: owners and Kilo + * staff may assign any role, admins may assign every role except owner, and + * nobody else may change roles. + */ const getAvailableRoles = ( currentUserRole: OrganizationRole, isKiloAdmin: boolean ): OrganizationRole[] => { - return isKiloAdmin || currentUserRole === 'owner' ? ['owner', 'member', 'billing_manager'] : []; + if (isKiloAdmin || currentUserRole === 'owner') { + return [...ASSIGNABLE_ROLES]; + } + if (currentUserRole === 'admin') { + return ['admin', 'member', 'billing_manager']; + } + return []; }; type MemberRoleDropdownProps = { @@ -84,7 +98,8 @@ export function MemberRoleDropdown({ }; const availableRoles = getAvailableRoles(currentUserRole, isKiloAdmin); - const canEditRole = availableRoles.length > 0; + // An admin may not act on an owner's membership, so show it as read-only. + const canEditRole = availableRoles.length > 0 && availableRoles.includes(member.role); // Check if this member is the current user by comparing both ID and email const isCurrentUser = @@ -118,7 +133,7 @@ export function MemberRoleDropdown({ - {(['owner', 'member', 'billing_manager'] as const).map(role => { + {ASSIGNABLE_ROLES.map(role => { const isCurrentRole = member.role === role; const isAllowed = availableRoles.includes(role); const isDisabled = isCurrentRole || mutation.isPending || !isAllowed; diff --git a/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx b/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx index 21e80e1edd6..ebffb1b0d94 100644 --- a/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx +++ b/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx @@ -35,6 +35,7 @@ import { modelRetainsPrompts, modelTrains, } from '@/lib/ai-gateway/providers/openrouter/model-data-policy'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; type Props = { organizationId: string; @@ -95,11 +96,11 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro const { assumedRole } = useRoleTesting(); const isKiloAdmin = assumedRole === 'KILO ADMIN'; const currentRole = (isKiloAdmin ? 'owner' : assumedRole) ?? role; - const canEdit = isKiloAdmin || currentRole === 'owner'; - // Auto routing card: owners, billing managers, and platform admins may edit. + const canEdit = isKiloAdmin || canManageOrganization(currentRole); + // Auto routing card: owners, admins, billing managers, and platform admins may edit. // Do not widen page-level canEdit (provider allow-list stays owner/admin only). const canEditAutoRouting = - isKiloAdmin || currentRole === 'owner' || currentRole === 'billing_manager'; + isKiloAdmin || canManageOrganization(currentRole) || currentRole === 'billing_manager'; const updateOrganizationSettings = useUpdateOrganizationSettings(); diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index e33b16c5c21..d5edd0ebc0c 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -18,6 +18,7 @@ import { import { Download, SlidersHorizontal } from 'lucide-react'; import type { Organization } from '@kilocode/db/schema'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; import { SummarySection } from './SummarySection'; import { PrimaryChart } from './PrimaryChart'; import { BreakdownPieChart } from './BreakdownPieChart'; @@ -717,7 +718,7 @@ export function UsageAnalyticsDashboard(props: UsageAnalyticsDashboardProps) { ) : ( diff --git a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts index 4d16edb4d0d..22a8f4fc532 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts @@ -21,6 +21,7 @@ import { } from '@/lib/integrations/platforms/bitbucket/credentials'; import { scheduleBitbucketRepositoryCachePrime } from '@/lib/integrations/platforms/bitbucket/repository-cache'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; type CallbackState = { owner: string; returnTo?: string }; @@ -68,7 +69,7 @@ async function authorizeOwner(owner: Owner, user: AuthenticatedOAuthUser): Promi if (owner.id !== user.id) throw new Error('OAuth owner mismatch'); return; } - await ensureOrganizationAccess({ user }, owner.id, ['owner', 'billing_manager']); + await ensureOrganizationAccess({ user }, owner.id, ORGANIZATION_BILLING_ROLES); } export async function handleBitbucketOAuthCallback(request: NextRequest): Promise { diff --git a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts index 13ed91f066c..e9dd121525f 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts @@ -12,6 +12,7 @@ import { } from '@/lib/integrations/oauth/common'; import { validateReturnPath } from '@/lib/integrations/validate-return-path'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; function detailPath(organizationId: string | null): string { @@ -33,7 +34,7 @@ export async function handleBitbucketOAuthConnect(request: NextRequest): Promise ? { type: 'org', id: organizationId } : { type: 'user', id: user.id }; if (owner.type === 'org') { - await ensureOrganizationAccess({ user }, owner.id, ['owner', 'billing_manager']); + await ensureOrganizationAccess({ user }, owner.id, ORGANIZATION_BILLING_ROLES); } const returnToParam = request.nextUrl.searchParams.get('returnTo'); diff --git a/apps/web/src/lib/integrations/oauth/routes.ts b/apps/web/src/lib/integrations/oauth/routes.ts index 1a8983fe34d..47b987e14d1 100644 --- a/apps/web/src/lib/integrations/oauth/routes.ts +++ b/apps/web/src/lib/integrations/oauth/routes.ts @@ -1,6 +1,7 @@ import 'server-only'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { PLATFORM } from '@/lib/integrations/core/constants'; import { handleStatefulPlatformOAuthConnect, @@ -38,7 +39,7 @@ const statefulOAuthConnectRouteConfigEntries = [ source: 'linear_oauth', loadBuildOAuthUrl: async () => (await import('@/lib/integrations/linear-service')).getLinearOAuthUrl, - organizationRoles: ['owner', 'billing_manager'], + organizationRoles: ORGANIZATION_BILLING_ROLES, requireActiveOrganizationSubscription: true, }, ], diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts b/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts index 74b637d4246..1f054e1d0d5 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts @@ -8,6 +8,7 @@ import { import { db } from '@/lib/drizzle'; import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; import type { Owner } from '@/lib/integrations/core/types'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { encryptKeyedEnvelope } from '@kilocode/encryption'; import { kilocode_users, @@ -177,7 +178,7 @@ export async function storeBitbucketIntegration(input: StoreBitbucketIntegration and( eq(organization_memberships.organization_id, input.owner.id), eq(organization_memberships.kilo_user_id, input.authorizedByUserId), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ) .for('update'); diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts index 1754ef3ab9a..4a20374fbb1 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts @@ -1,6 +1,7 @@ import 'server-only'; import { kilocode_users, organization_memberships, organizations } from '@kilocode/db/schema'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { buildBitbucketOrganizationCredentialLockKey } from '@kilocode/worker-utils/bitbucket-workspace-access-token'; import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; import type { DrizzleTransaction } from '@/lib/drizzle'; @@ -71,7 +72,7 @@ export async function requireBitbucketWorkspaceAccessTokenOrganizationManager( and( eq(organization_memberships.organization_id, organizationId), eq(organization_memberships.kilo_user_id, actorUserId), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ) .for('update'); diff --git a/apps/web/src/lib/integrations/resolve-owner.ts b/apps/web/src/lib/integrations/resolve-owner.ts index 0196dea74f6..863552e4e89 100644 --- a/apps/web/src/lib/integrations/resolve-owner.ts +++ b/apps/web/src/lib/integrations/resolve-owner.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { Owner } from './core/types'; import type { TRPCContext } from '@/lib/trpc/init'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; @@ -27,7 +28,7 @@ export async function ensureIntegrationAccess( roles?: OrganizationRole[] ) { if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, roles ?? ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, roles ?? ORGANIZATION_BILLING_ROLES); } } diff --git a/apps/web/src/lib/kilo-pass-org/notifications.ts b/apps/web/src/lib/kilo-pass-org/notifications.ts index 836ebf87371..2740b14bad5 100644 --- a/apps/web/src/lib/kilo-pass-org/notifications.ts +++ b/apps/web/src/lib/kilo-pass-org/notifications.ts @@ -8,6 +8,7 @@ import { organization_memberships, organizations, } from '@kilocode/db/schema'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { and, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm'; import { captureException } from '@sentry/nextjs'; import { db } from '@/lib/drizzle'; @@ -90,7 +91,7 @@ export async function dispatchOrganizationPassBlockedNotifications( .where( and( inArray(kilo_pass_org_notification_deliveries.id, claimedIds), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ); diff --git a/apps/web/src/lib/kilo-pass-org/service.ts b/apps/web/src/lib/kilo-pass-org/service.ts index 042dd210278..fa3a678ea61 100644 --- a/apps/web/src/lib/kilo-pass-org/service.ts +++ b/apps/web/src/lib/kilo-pass-org/service.ts @@ -15,6 +15,7 @@ import { organization_seats_purchases, organizations, } from '@kilocode/db/schema'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { KiloPassOrgAgreementState, KiloPassOrgBonusMode, @@ -643,7 +644,7 @@ async function issue( .where( and( eq(organization_memberships.organization_id, row.agreement.parent_organization_id), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ); const recipientIds = new Set(recipients.map(recipient => recipient.userId)); @@ -1343,7 +1344,7 @@ async function agreementRecipientUserId( .where( and( eq(organization_memberships.organization_id, parentOrganizationId), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ) .orderBy(asc(organization_memberships.joined_at)) diff --git a/apps/web/src/lib/organizations/organization-auth.ts b/apps/web/src/lib/organizations/organization-auth.ts index 6101f1d92ac..817671dec6a 100644 --- a/apps/web/src/lib/organizations/organization-auth.ts +++ b/apps/web/src/lib/organizations/organization-auth.ts @@ -14,8 +14,14 @@ import z from 'zod'; const warnInSentry = sentryLogger('org_auth', 'warning'); -const parentOrganizationAccessRoles = ['owner', 'billing_manager'] satisfies OrganizationRole[]; -const rolePriority = ['owner', 'billing_manager', 'member'] satisfies OrganizationRole[]; +// Mirrors routers/organizations/utils.ts: parent-organization roles that +// inherit access to child organizations. +const parentOrganizationAccessRoles = [ + 'owner', + 'admin', + 'billing_manager', +] satisfies OrganizationRole[]; +const rolePriority = ['owner', 'admin', 'billing_manager', 'member'] satisfies OrganizationRole[]; type UserWithRole = User & { readonly role: OrganizationRole; diff --git a/apps/web/src/lib/organizations/organization-shared-utils.tsx b/apps/web/src/lib/organizations/organization-shared-utils.tsx index 2fc42f52fe0..05a57a09c23 100644 --- a/apps/web/src/lib/organizations/organization-shared-utils.tsx +++ b/apps/web/src/lib/organizations/organization-shared-utils.tsx @@ -26,6 +26,10 @@ export const getRoleLabel = (role: string) => { switch (role) { case 'owner': return 'Owner'; + case 'admin': + return 'Admin'; + case 'billing_manager': + return 'Billing Manager'; case 'member': return 'Member'; default: diff --git a/apps/web/src/routers/byok-router.ts b/apps/web/src/routers/byok-router.ts index 78f0ddceacc..dda4d084956 100644 --- a/apps/web/src/routers/byok-router.ts +++ b/apps/web/src/routers/byok-router.ts @@ -1,4 +1,5 @@ import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; @@ -158,7 +159,7 @@ export const byokRouter = createTRPCRouter({ // If organizationId provided, verify owner/billing access if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } // Encrypt the API key @@ -210,7 +211,7 @@ export const byokRouter = createTRPCRouter({ // If organizationId provided, verify owner/billing access if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } // Verify key exists and belongs to the organization or user @@ -294,7 +295,7 @@ export const byokRouter = createTRPCRouter({ const { organizationId, id, is_enabled } = input; if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } const [existingKey] = await db @@ -372,7 +373,7 @@ export const byokRouter = createTRPCRouter({ const { organizationId, id } = input; if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } const [existingKey] = await db @@ -477,7 +478,7 @@ export const byokRouter = createTRPCRouter({ // If organizationId provided, verify owner/billing access if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } // Verify key exists and belongs to the organization or user diff --git a/apps/web/src/routers/code-reviews/code-review-analytics-router.ts b/apps/web/src/routers/code-reviews/code-review-analytics-router.ts index b719b8a4783..0e980e7d07e 100644 --- a/apps/web/src/routers/code-reviews/code-review-analytics-router.ts +++ b/apps/web/src/routers/code-reviews/code-review-analytics-router.ts @@ -5,6 +5,7 @@ import { setReviewAnalyticsEnabled } from '@/lib/code-reviews/analytics/settings import { readDb } from '@/lib/drizzle'; import { createTRPCRouter, baseProcedure } from '@/lib/trpc/init'; import { timedUsageQuery } from '@/lib/usage-query'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; const DAY_IN_MS = 24 * 60 * 60 * 1000; @@ -55,7 +56,7 @@ export const codeReviewAnalyticsRouter = createTRPCRouter({ }), setEnabled: baseProcedure.input(SetEnabledInputSchema).mutation(async ({ ctx, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); const enabled = await setReviewAnalyticsEnabled({ owner: { type: 'org', id: input.organizationId }, diff --git a/apps/web/src/routers/code-reviews/review-memory-router.ts b/apps/web/src/routers/code-reviews/review-memory-router.ts index dac9e6b934c..dd48bab8098 100644 --- a/apps/web/src/routers/code-reviews/review-memory-router.ts +++ b/apps/web/src/routers/code-reviews/review-memory-router.ts @@ -21,6 +21,7 @@ import { ReviewMemoryChangeRequestError, } from '@/lib/code-reviews/review-memory/change-request'; import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { REVIEW_MEMORY_PROPOSAL_STATUSES } from '@kilocode/db/schema-types'; @@ -86,7 +87,7 @@ export const reviewMemoryRouter = createTRPCRouter({ setEnabled: baseProcedure .input(PlatformOwnerInputSchema.extend({ enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); const enabled = await setReviewMemoryEnabled({ owner, platform: input.platform, @@ -99,7 +100,7 @@ export const reviewMemoryRouter = createTRPCRouter({ triggerAnalysis: baseProcedure .input(PlatformOwnerInputSchema.extend({ repoFullName: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); return await runReviewMemoryAnalysis({ owner, @@ -119,7 +120,7 @@ export const reviewMemoryRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); const proposal = await updateProposal({ owner, @@ -135,7 +136,7 @@ export const reviewMemoryRouter = createTRPCRouter({ rejectProposal: baseProcedure .input(PlatformOwnerInputSchema.extend({ proposalId: z.uuid() })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); const proposal = await rejectProposal({ owner, proposalId: input.proposalId }); if (!proposal) throw new TRPCError({ code: 'NOT_FOUND', message: 'Proposal not found.' }); @@ -145,7 +146,7 @@ export const reviewMemoryRouter = createTRPCRouter({ approveAndOpenChangeRequest: baseProcedure .input(PlatformOwnerInputSchema.extend({ proposalId: z.uuid() })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); try { return await approveAndOpenReviewMemoryChangeRequest({ diff --git a/apps/web/src/routers/gitlab-router.ts b/apps/web/src/routers/gitlab-router.ts index 912d4fa2c65..fe2a5782a4a 100644 --- a/apps/web/src/routers/gitlab-router.ts +++ b/apps/web/src/routers/gitlab-router.ts @@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server'; import * as z from 'zod'; import * as gitlabService from '@/lib/integrations/gitlab-service'; import { getValidGitLabToken } from '@/lib/integrations/gitlab-service'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { resolveOwner, @@ -69,7 +70,7 @@ export const gitlabRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { if (input.organizationId) { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); } const owner = resolveOwner(ctx, input.organizationId); return gitlabService.connectWithPAT(owner, input.token, input.instanceUrl, ctx.user.id); diff --git a/apps/web/src/routers/organizations/organization-admin-router.ts b/apps/web/src/routers/organizations/organization-admin-router.ts index df6d79e939e..b13420e84a9 100644 --- a/apps/web/src/routers/organizations/organization-admin-router.ts +++ b/apps/web/src/routers/organizations/organization-admin-router.ts @@ -39,6 +39,7 @@ import { addUserToOrganization, markOrganizationAsDeleted, } from '@/lib/organizations/organizations'; +import { OrganizationRoleSchema } from '@/lib/organizations/organization-types'; import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing'; import { findUserById } from '@/lib/user'; import { TRPCError } from '@trpc/server'; @@ -240,7 +241,7 @@ const OrganizationMetricsSchema = z.object({ const AddMemberInputSchema = z.object({ organizationId: z.uuid(), userId: z.string(), - role: z.enum(['owner', 'member', 'billing_manager']), + role: OrganizationRoleSchema, }); const childOrganizationSettings = { diff --git a/apps/web/src/routers/organizations/organization-groups-router.ts b/apps/web/src/routers/organizations/organization-groups-router.ts index d63faa76b7a..9a0f443bb4a 100644 --- a/apps/web/src/routers/organizations/organization-groups-router.ts +++ b/apps/web/src/routers/organizations/organization-groups-router.ts @@ -28,7 +28,7 @@ import { OrganizationIdInputSchema, organizationBillingProcedure, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; import { getModelAccessPolicyEditorData } from '@/lib/organizations/group-policies/model-access/model-access.server'; @@ -108,7 +108,7 @@ export const organizationGroupsRouter = createTRPCRouter({ return { group }; }), - create: organizationOwnerMutationProcedure + create: organizationAdminMutationProcedure .input(CreateGroupInputSchema) .mutation(async ({ input, ctx }) => { return await createOrganizationGroup({ @@ -120,7 +120,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - updateMetadata: organizationOwnerMutationProcedure + updateMetadata: organizationAdminMutationProcedure .input(UpdateMetadataInputSchema) .mutation(async ({ input, ctx }) => { return await updateOrganizationGroupMetadata({ @@ -132,7 +132,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - updateDetails: organizationOwnerMutationProcedure + updateDetails: organizationAdminMutationProcedure .input(UpdateGroupDetailsInputSchema) .mutation(async ({ input, ctx }) => { return await updateOrganizationGroupDetails({ @@ -145,7 +145,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - setPolicy: organizationOwnerMutationProcedure + setPolicy: organizationAdminMutationProcedure .input(SetPolicyInputSchema) .mutation(async ({ input, ctx }) => { return await setOrganizationGroupPolicy({ @@ -156,7 +156,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - removePolicy: organizationOwnerMutationProcedure + removePolicy: organizationAdminMutationProcedure .input(RemovePolicyInputSchema) .mutation(async ({ input, ctx }) => { return await removeOrganizationGroupPolicy({ @@ -167,7 +167,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - setMembers: organizationOwnerMutationProcedure + setMembers: organizationAdminMutationProcedure .input(SetMembersInputSchema) .mutation(async ({ input, ctx }) => { return await setOrganizationGroupMembers({ @@ -178,7 +178,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - setMemberGroups: organizationOwnerMutationProcedure + setMemberGroups: organizationAdminMutationProcedure .input(SetMemberGroupsInputSchema) .mutation(async ({ input, ctx }) => { return await setOrganizationMemberGroups({ @@ -189,7 +189,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - delete: organizationOwnerMutationProcedure + delete: organizationAdminMutationProcedure .input(GroupIdInputSchema) .mutation(async ({ input, ctx }) => { return await deleteOrganizationGroup({ @@ -216,7 +216,7 @@ export const organizationGroupsRouter = createTRPCRouter({ } }), - setDefaultPolicy: organizationOwnerMutationProcedure + setDefaultPolicy: organizationAdminMutationProcedure .input(SetDefaultPolicyInputSchema) .mutation(async ({ input, ctx }) => { return await setDefaultOrganizationGroupPolicy({ @@ -226,7 +226,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - removeDefaultPolicy: organizationOwnerMutationProcedure + removeDefaultPolicy: organizationAdminMutationProcedure .input( OrganizationIdInputSchema.extend({ policyType: OrganizationGroupPolicyTypeSchema }).strict() ) diff --git a/apps/web/src/routers/organizations/organization-members-router.test.ts b/apps/web/src/routers/organizations/organization-members-router.test.ts index 76ae1df3312..b638d105693 100644 --- a/apps/web/src/routers/organizations/organization-members-router.test.ts +++ b/apps/web/src/routers/organizations/organization-members-router.test.ts @@ -32,6 +32,8 @@ let adminUser: User; let memberUser: User; let billingManagerUser: User; let nonMemberUser: User; +// Holds the `admin` organization role (distinct from `adminUser`, who is Kilo staff). +let orgAdminUser: User; let testOrganization: Organization; describe('organizations members trpc router', () => { @@ -67,12 +69,19 @@ describe('organizations members trpc router', () => { is_admin: false, }); + orgAdminUser = await insertTestUser({ + google_user_email: 'org-admin-members@example.com', + google_user_name: 'Org Admin Members User', + is_admin: false, + }); + // Create test organization using the CRUD method testOrganization = await createOrganization('Test Members Organization', regularUser.id); // Add member user to organization using CRUD method await addUserToOrganization(testOrganization.id, memberUser.id, 'member'); await addUserToOrganization(testOrganization.id, billingManagerUser.id, 'billing_manager'); + await addUserToOrganization(testOrganization.id, orgAdminUser.id, 'admin'); }); describe('listPublic procedure', () => { @@ -182,6 +191,82 @@ describe('organizations members trpc router', () => { ).rejects.toThrow('You cannot change your own role'); }); + it('lets an organization admin update a member role, matching owner authority', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-update.example.com`, + google_user_name: 'Org Admin Update Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); + + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'billing_manager', + }); + + expect(result).toEqual({ success: true, updated: 'role and limit' }); + }); + + it('lets an organization admin grant the admin role', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-grant-admin.example.com`, + google_user_name: 'Org Admin Grant Admin Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); + + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'admin', + }); + + expect(result).toEqual({ success: true, updated: 'role and limit' }); + }); + + it('rejects an organization admin granting the owner role', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-promote.example.com`, + google_user_name: 'Org Admin Promote Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); + + const caller = await createCallerForUser(orgAdminUser.id); + + await expect( + caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'owner', + }) + ).rejects.toThrow('Only an organization owner can manage owners'); + }); + + it('rejects an organization admin changing an existing owner role', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-demote.example.com`, + google_user_name: 'Org Admin Demote Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'owner'); + + const caller = await createCallerForUser(orgAdminUser.id); + + await expect( + caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'member', + }) + ).rejects.toThrow('Only an organization owner can manage owners'); + }); + it('should throw FORBIDDEN error when non-owner tries to assign owner role', async () => { // Create a test user to be the target of the role update const targetUser = await insertTestUser({ @@ -603,6 +688,42 @@ describe('organizations members trpc router', () => { ).rejects.toThrow('You do not have access to this organization'); }); + it('lets an organization admin remove a non-owner member', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-remove-member.example.com`, + google_user_name: 'Org Admin Remove Member Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); + + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.remove({ + organizationId: testOrganization.id, + memberId: targetUser.id, + }); + + expect(result).toEqual({ success: true, updated: targetUser.id }); + }); + + it('rejects an organization admin removing an owner', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-remove-owner.example.com`, + google_user_name: 'Org Admin Remove Owner Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'owner'); + + const caller = await createCallerForUser(orgAdminUser.id); + + await expect( + caller.organizations.members.remove({ + organizationId: testOrganization.id, + memberId: targetUser.id, + }) + ).rejects.toThrow('Only an organization owner can manage owners'); + }); + it('should reject billing managers removing owners', async () => { const caller = await createCallerForUser(billingManagerUser.id); @@ -654,6 +775,30 @@ describe('organizations members trpc router', () => { expect(result.acceptInviteUrl).toMatch(/^https?:\/\/.+\/users\/accept-invite\/.+$/); }); + it('should allow an organization admin to invite an admin', async () => { + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.invite({ + organizationId: testOrganization.id, + email: `${crypto.randomUUID()}@org-admin-invite-admin.example.com`, + role: 'admin', + }); + + expect(result).toHaveProperty('acceptInviteUrl'); + }); + + it('should reject an organization admin inviting an owner', async () => { + const caller = await createCallerForUser(orgAdminUser.id); + + await expect( + caller.organizations.members.invite({ + organizationId: testOrganization.id, + email: `${crypto.randomUUID()}@org-admin-invite-owner.example.com`, + role: 'owner', + }) + ).rejects.toThrow('Only an organization owner can manage owners'); + }); + it('should allow system admin to invite any role', async () => { const caller = await createCallerForUser(adminUser.id); diff --git a/apps/web/src/routers/organizations/organization-members-router.ts b/apps/web/src/routers/organizations/organization-members-router.ts index 36400e13c47..5ba239109d8 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -21,8 +21,9 @@ import { OrganizationIdInputSchema, organizationBillingMutationProcedure, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; +import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations'; import { sendOrganizationInviteEmail } from '@/lib/email'; import { TRPCError } from '@trpc/server'; import { and, eq, inArray, isNull } from 'drizzle-orm'; @@ -33,13 +34,17 @@ import { successResult } from '@/lib/maybe-result'; import { destroyOrgInstancesForUser } from '@/lib/kiloclaw/instance-registry'; import { KiloClawInternalClient } from '@/lib/kiloclaw/kiloclaw-internal-client'; import { revokeGatewayStateForOrganizationMember } from '@/lib/mcp-gateway/lifecycle-service'; -import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types'; +import { + OrganizationRoleSchema, + PublicOrganizationMembersSchema, +} from '@/lib/organizations/organization-types'; +import type { OrganizationRole } from '@/lib/organizations/organization-types'; const MAX_DAILY_LIMIT_USD = 2000; const UpdateMemberSchema = OrganizationIdInputSchema.extend({ memberId: z.string(), - role: z.enum(['owner', 'member', 'billing_manager']).optional(), + role: OrganizationRoleSchema.optional(), dailyUsageLimitUsd: z.number().min(0).max(MAX_DAILY_LIMIT_USD).nullable().optional(), }); @@ -49,7 +54,7 @@ const RemoveMemberSchema = OrganizationIdInputSchema.extend({ const InviteMemberSchema = OrganizationIdInputSchema.extend({ email: z.email('Invalid email address'), - role: z.enum(['owner', 'member', 'billing_manager']), + role: OrganizationRoleSchema, }); const DeleteInviteSchema = OrganizationIdInputSchema.extend({ @@ -61,10 +66,33 @@ const SetChildMembershipsSchema = OrganizationIdInputSchema.extend({ childOrganizationIds: z.array(z.uuid()), }); +const OWNER_AUTHORITY_MESSAGE = 'Only an organization owner can manage owners'; + +/** + * Reserves owner authority for owners. Admins match owners everywhere else, but + * may not grant the owner role nor act on an existing owner's membership. Both + * halves are required: allowing only one would let an admin strip every owner + * while being unable to appoint a replacement, leaving the org with no owner. + * + * Kilo staff bypass this because `ensureOrganizationAccess` resolves them to + * `owner`. + */ +function assertOwnerAuthority( + actorRole: OrganizationRole, + target: { currentRole?: OrganizationRole | null; nextRole?: OrganizationRole | null } +): void { + if (actorRole === 'owner') { + return; + } + if (target.currentRole === 'owner' || target.nextRole === 'owner') { + throw new TRPCError({ code: 'FORBIDDEN', message: OWNER_AUTHORITY_MESSAGE }); + } +} + async function getDirectOrganizationRole( organizationId: string, userId: string -): Promise<'owner' | 'member' | 'billing_manager' | null> { +): Promise { const [membership] = await db .select({ role: organization_memberships.role }) .from(organization_memberships) @@ -87,14 +115,14 @@ export const organizationsMembersRouter = createTRPCRouter({ return await getOrganizationMembers(input.organizationId); }), - update: organizationOwnerMutationProcedure + update: organizationAdminMutationProcedure .input(UpdateMemberSchema) .mutation(async ({ input, ctx }) => { const { user } = ctx; const { organizationId, memberId, role, dailyUsageLimitUsd } = input; // Get the target user's role if we need to check permissions for role or limit changes - let targetMember: { role: string } | undefined; + let targetMember: { role: OrganizationRole } | undefined; if (role !== undefined || dailyUsageLimitUsd !== undefined) { const [member] = await db .select({ role: organization_memberships.role }) @@ -126,6 +154,13 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { currentRole: targetMember?.role, nextRole: role }); + const result = await updateUserRoleInOrganization(organizationId, memberId, role); const updatedUser = await findUserById(memberId); const updatedUserEmail = updatedUser?.google_user_email || 'unknown'; @@ -283,7 +318,7 @@ export const organizationsMembersRouter = createTRPCRouter({ return successResult({ added, removed }); }), - remove: organizationOwnerMutationProcedure + remove: organizationAdminMutationProcedure .input(RemoveMemberSchema) .mutation(async ({ input, ctx }) => { const { user } = ctx; @@ -327,6 +362,13 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { currentRole: targetMember.role }); + const result = await removeUserFromOrganization(organizationId, memberId, user.id); if (result.rowCount === 0) { throw new TRPCError({ @@ -389,8 +431,16 @@ export const organizationsMembersRouter = createTRPCRouter({ const { user } = ctx; const { organizationId, email, role } = input; + // Members can be invited by any billing-capable role; elevated roles + // require organization-management authority, and only an owner may invite + // another owner. if (role !== 'member') { - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { nextRole: role }); } // Get organization details @@ -402,7 +452,8 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - // Owners and Kilo admins can invite any role. Billing managers can invite members only. + // Owners and Kilo staff can invite any role. Admins can invite any role + // except owner. Billing managers can invite members only. let invitation; try { invitation = await inviteUserToOrganization(organizationId, user.id, email, role); @@ -476,7 +527,7 @@ export const organizationsMembersRouter = createTRPCRouter({ acceptInviteUrl, }; }), - deleteInvite: organizationOwnerMutationProcedure + deleteInvite: organizationAdminMutationProcedure .input(DeleteInviteSchema) .mutation(async ({ input, ctx }) => { const { organizationId, inviteId } = input; @@ -500,7 +551,15 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - // Owners can delete any invitation + // Owners can revoke any invitation; admins cannot revoke an owner invite, + // which is a pending grant of owner authority. + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { currentRole: invitation.role }); + // Expire the invitation by setting expires_at to NOW await db .update(organization_invitations) diff --git a/apps/web/src/routers/organizations/organization-modes-router.ts b/apps/web/src/routers/organizations/organization-modes-router.ts index 8854d0c73f2..47af1aac5fa 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.ts @@ -5,6 +5,7 @@ import { organizationMemberProcedure, organizationMemberMutationProcedure, } from '@/routers/organizations/utils'; +import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; import { @@ -98,7 +99,7 @@ async function applyOrganizationAutoRouteChange( ): Promise { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organization.id, ['owner']); + await ensureOrganizationAccess(ctx, organization.id, ORGANIZATION_MANAGE_ROLES); const orgAutoModel = getOrganizationAutoSettings(organization.settings); const currentRoute = orgAutoModel.routes[modeSlug]; if (routeModel === null && !currentRoute) { @@ -225,7 +226,7 @@ export const organizationModesRouter = createTRPCRouter({ if (route_model !== undefined) { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); } let createdMode: OrganizationMode | null | undefined; @@ -334,7 +335,7 @@ export const organizationModesRouter = createTRPCRouter({ if (route_model !== undefined) { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); } const hasModeUpdates = @@ -365,7 +366,7 @@ export const organizationModesRouter = createTRPCRouter({ let nextSettings = lockedOrganization.settings; if (sourceHasRoute && slugChanged) { - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); if (hasOrganizationAutoRoute(routes, nextSlug)) { throw new TRPCError({ code: 'CONFLICT', @@ -477,7 +478,7 @@ export const organizationModesRouter = createTRPCRouter({ if (route_model !== undefined) { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); } const routeAuditChanges: string[] = []; @@ -525,7 +526,7 @@ export const organizationModesRouter = createTRPCRouter({ ); } } else if (hasExistingRoute) { - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); if (!preserveBuiltInRoute) { routeAuditChanges.push( diff --git a/apps/web/src/routers/organizations/organization-settings-router.ts b/apps/web/src/routers/organizations/organization-settings-router.ts index dd5b3e22430..3f333d06ff9 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -13,7 +13,7 @@ import { OrganizationIdInputSchema, organizationBillingMutationProcedure, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; @@ -337,7 +337,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return result; }), - updateAllowLists: organizationOwnerMutationProcedure + updateAllowLists: organizationAdminMutationProcedure .input(UpdateAllowListsInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -406,7 +406,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - updateDefaultModel: organizationOwnerMutationProcedure + updateDefaultModel: organizationAdminMutationProcedure .input(UpdateDefaultModelInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -476,7 +476,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - setOrganizationAutoRoute: organizationOwnerMutationProcedure + setOrganizationAutoRoute: organizationAdminMutationProcedure .input(SetOrganizationAutoRouteInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -544,7 +544,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - clearOrganizationAutoRoute: organizationOwnerMutationProcedure + clearOrganizationAutoRoute: organizationAdminMutationProcedure .input(ClearOrganizationAutoRouteInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -598,7 +598,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - configureOrganizationDefaultBehavior: organizationOwnerMutationProcedure + configureOrganizationDefaultBehavior: organizationAdminMutationProcedure .input(ConfigureOrganizationDefaultBehaviorInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -892,7 +892,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ // Owners-only: toggle the weekly enterprise recommendations digest email. // Enterprise-gated and owner-only (matching the recommendations dismiss/restore // permission model). When on, the digest is emailed to the org's owners. - updateRecommendationsDigest: organizationOwnerMutationProcedure + updateRecommendationsDigest: organizationAdminMutationProcedure .input(UpdateRecommendationsDigestInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { diff --git a/apps/web/src/routers/organizations/organization-sso-router.ts b/apps/web/src/routers/organizations/organization-sso-router.ts index 3ce5a2e1e6c..4b61c156d13 100644 --- a/apps/web/src/routers/organizations/organization-sso-router.ts +++ b/apps/web/src/routers/organizations/organization-sso-router.ts @@ -6,6 +6,7 @@ import { ensureOrganizationAccess, organizationMemberProcedure, } from '@/routers/organizations/utils'; +import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import { GeneratePortalLinkIntent, WorkOS, OrganizationDomainState } from '@workos-inc/node'; import * as z from 'zod'; @@ -50,7 +51,7 @@ async function hasWorkOsConnections(organizationId: string) { export const organizationSsoRouter = createTRPCRouter({ createConfig: adminProcedure.input(OrgIdSchema).mutation(async opts => { const { organizationId } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); const workOSOrg = await getWorkOsOrganizationByExternalId(organizationId); if (workOSOrg) { throw new TRPCError({ @@ -100,7 +101,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), deleteConfig: adminProcedure.input(OrgIdSchema).mutation(async opts => { const { organizationId } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); const workOSOrg = await getWorkOsOrganizationByExternalId(organizationId); if (!workOSOrg) { throw new TRPCError({ code: 'NOT_FOUND', message: 'SSO configuration not found' }); @@ -110,7 +111,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), generateAdminPortalLink: adminProcedure.input(AdminPortalSchema).mutation(async opts => { const { organizationId, linkType } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); const workOSOrg = await getWorkOsOrganizationByExternalId(organizationId); if (!workOSOrg) { throw new TRPCError({ code: 'NOT_FOUND', message: 'SSO configuration not found' }); @@ -129,7 +130,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), updateSsoDomain: adminProcedure.input(UpdateSSODomainSchema).mutation(async opts => { const { organizationId, ssoDomain } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); await db.transaction(async tx => { const [organization] = await tx @@ -182,7 +183,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), clearSsoDomain: adminProcedure.input(OrgIdSchema).mutation(async opts => { const { organizationId } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); await db .update(organizations) diff --git a/apps/web/src/routers/organizations/organization-usage-details-router.ts b/apps/web/src/routers/organizations/organization-usage-details-router.ts index 6087a352cee..369ddeb2d85 100644 --- a/apps/web/src/routers/organizations/organization-usage-details-router.ts +++ b/apps/web/src/routers/organizations/organization-usage-details-router.ts @@ -2,7 +2,7 @@ import { createTRPCRouter } from '@/lib/trpc/init'; import { OrganizationIdInputSchema, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; import { db, readDb } from '@/lib/drizzle'; import { organizations, organization_recommendation_dismissals } from '@kilocode/db/schema'; @@ -153,7 +153,7 @@ export const organizationsUsageDetailsRouter = createTRPCRouter({ } return { checks: result.checks, recommendations: result.recommendations }; }), - dismissRecommendation: organizationOwnerMutationProcedure + dismissRecommendation: organizationAdminMutationProcedure .input(DismissRecommendationInputSchema) .mutation(async ({ ctx, input }) => { await assertEnterprise(input.organizationId); @@ -172,7 +172,7 @@ export const organizationsUsageDetailsRouter = createTRPCRouter({ }); return { dismissed: true }; }), - restoreRecommendation: organizationOwnerMutationProcedure + restoreRecommendation: organizationAdminMutationProcedure .input(DismissRecommendationInputSchema) .mutation(async ({ input }) => { await assertEnterprise(input.organizationId); diff --git a/apps/web/src/routers/organizations/utils.ts b/apps/web/src/routers/organizations/utils.ts index 63cf0528759..0fefeec5826 100644 --- a/apps/web/src/routers/organizations/utils.ts +++ b/apps/web/src/routers/organizations/utils.ts @@ -3,6 +3,10 @@ import { organization_memberships, organizations } from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; +import { + ORGANIZATION_BILLING_ROLES, + ORGANIZATION_MANAGE_ROLES, +} from '@kilocode/app-shared/organizations'; import { baseProcedure } from '@/lib/trpc/init'; import type { TRPCContext } from '@/lib/trpc/init'; import { TRPCError } from '@trpc/server'; @@ -13,8 +17,15 @@ export const OrganizationIdInputSchema = z.object({ organizationId: z.uuid(), }); -const parentOrganizationAccessRoles = ['owner', 'billing_manager'] satisfies OrganizationRole[]; -const rolePriority = ['owner', 'billing_manager', 'member'] satisfies OrganizationRole[]; +// Roles in a parent organization that inherit access to its children. Kept +// separate from ORGANIZATION_BILLING_ROLES so changing billing scope cannot +// silently widen child-organization inheritance. +const parentOrganizationAccessRoles = [ + 'owner', + 'admin', + 'billing_manager', +] satisfies OrganizationRole[]; +const rolePriority = ['owner', 'admin', 'billing_manager', 'member'] satisfies OrganizationRole[]; function allowedRole( rows: { role: OrganizationRole }[], @@ -262,19 +273,19 @@ export const organizationMemberMutationProcedure = baseProcedure return next(); }); -// Custom procedure that ensures user has owner access to the organization -export const organizationOwnerProcedure = baseProcedure +// Ensures the caller can manage the organization (owner or admin). +export const organizationAdminProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_MANAGE_ROLES); return next(); }); -// Owner procedure that also enforces trial/subscription status on mutations -export const organizationOwnerMutationProcedure = baseProcedure +// Owner/admin procedure that also enforces trial/subscription status on mutations +export const organizationAdminMutationProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_MANAGE_ROLES); await requireActiveSubscriptionOrTrial(input.organizationId); return next(); }); @@ -283,7 +294,7 @@ export const organizationOwnerMutationProcedure = baseProcedure export const organizationBillingProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); return next(); }); @@ -291,7 +302,7 @@ export const organizationBillingProcedure = baseProcedure export const organizationBillingMutationProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); await requireActiveSubscriptionOrTrial(input.organizationId); return next(); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 6022d086f6e..519bdbe14fb 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -16,6 +16,7 @@ import { user_auth_provider, } from '@kilocode/db/schema'; import type { AuthProviderId } from '@kilocode/db/schema-types'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess, ensureOrganizationsAccess, @@ -221,18 +222,13 @@ async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilter // Batched into a fixed number of queries so a large org list cannot fan out // into one authorization query per id. if (filters.organizationIds && filters.organizationIds.length > 0) { - await ensureOrganizationsAccess(ctx, filters.organizationIds, ['owner', 'billing_manager']); + await ensureOrganizationsAccess(ctx, filters.organizationIds, ORGANIZATION_BILLING_ROLES); return; } if (filters.organizationId) { - const requiredRoles = - filters.viewAs === 'org-wide' ? (['owner', 'billing_manager'] as const) : undefined; - await ensureOrganizationAccess( - ctx, - filters.organizationId, - requiredRoles ? [...requiredRoles] : undefined - ); + const requiredRoles = filters.viewAs === 'org-wide' ? ORGANIZATION_BILLING_ROLES : undefined; + await ensureOrganizationAccess(ctx, filters.organizationId, requiredRoles); if (filters.viewAs === 'self') { const allUserFilterValues = [...(filters.userIds ?? []), ...(filters.excludedUserIds ?? [])]; @@ -1030,7 +1026,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ .input(ScopeOrganizationsInputSchema) .output(ScopeOrganizationsOutputSchema) .query(async ({ input, ctx }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); const [org] = await readDb .select({ id: organizations.id, name: organizations.name }) diff --git a/packages/app-shared/src/organizations/roles.test.ts b/packages/app-shared/src/organizations/roles.test.ts index e1655dfb8c9..9a635900f6e 100644 --- a/packages/app-shared/src/organizations/roles.test.ts +++ b/packages/app-shared/src/organizations/roles.test.ts @@ -1,22 +1,54 @@ import { describe, expect, it } from 'vitest'; -import { canManageOrganizationBilling, ORGANIZATION_ROLES } from './roles'; +import { + canManageOrganization, + canManageOrganizationBilling, + canManageOrganizationOwners, + ORGANIZATION_ROLES, +} from './roles'; describe('ORGANIZATION_ROLES', () => { - it('is exactly owner, member, billing_manager', () => { - expect(ORGANIZATION_ROLES).toEqual(['owner', 'member', 'billing_manager']); + it('is exactly owner, admin, member, billing_manager', () => { + expect(ORGANIZATION_ROLES).toEqual(['owner', 'admin', 'member', 'billing_manager']); }); }); describe('canManageOrganizationBilling', () => { - it('is true for owner and billing_manager', () => { + it('is true for owner, admin and billing_manager', () => { expect(canManageOrganizationBilling('owner')).toBe(true); + expect(canManageOrganizationBilling('admin')).toBe(true); expect(canManageOrganizationBilling('billing_manager')).toBe(true); }); it('is false for member, undefined, and unrelated strings', () => { expect(canManageOrganizationBilling('member')).toBe(false); expect(canManageOrganizationBilling(undefined)).toBe(false); - expect(canManageOrganizationBilling('admin')).toBe(false); + expect(canManageOrganizationBilling('kilo_staff')).toBe(false); + }); +}); + +describe('canManageOrganizationOwners', () => { + it('is true only for owner', () => { + expect(canManageOrganizationOwners('owner')).toBe(true); + }); + + it('excludes admin so admins cannot appoint or remove owners', () => { + expect(canManageOrganizationOwners('admin')).toBe(false); + expect(canManageOrganizationOwners('billing_manager')).toBe(false); + expect(canManageOrganizationOwners('member')).toBe(false); + expect(canManageOrganizationOwners(undefined)).toBe(false); + }); +}); + +describe('canManageOrganization', () => { + it('includes owner and admin', () => { + expect(canManageOrganization('owner')).toBe(true); + expect(canManageOrganization('admin')).toBe(true); + }); + + it('is false for billing_manager, member and undefined', () => { + expect(canManageOrganization('billing_manager')).toBe(false); + expect(canManageOrganization('member')).toBe(false); + expect(canManageOrganization(undefined)).toBe(false); }); }); diff --git a/packages/app-shared/src/organizations/roles.ts b/packages/app-shared/src/organizations/roles.ts index 1ca84e32f5c..1cd489004a3 100644 --- a/packages/app-shared/src/organizations/roles.ts +++ b/packages/app-shared/src/organizations/roles.ts @@ -1,22 +1,53 @@ // Canonical organization role values. Matches apps/web's // OrganizationRoleSchema (lib/organizations/organization-types.ts, a -// z.enum(['owner', 'member', 'billing_manager'])) and packages/db's -// OrganizationRole type ('owner' | 'member' | 'billing_manager') — verified -// identical while porting. Mobile's local string-union copies (e.g. -// lib/security-agent.ts) also match, just declared in a different member -// order (order doesn't affect the union type). -export const ORGANIZATION_ROLES = ['owner', 'member', 'billing_manager'] as const; +// z.enum(ORGANIZATION_ROLES)) and packages/db's OrganizationRole type. +export const ORGANIZATION_ROLES = ['owner', 'admin', 'member', 'billing_manager'] as const; export type OrganizationRole = (typeof ORGANIZATION_ROLES)[number]; /** - * True for roles that may manage organization billing (owner or + * Roles with organization-management authority. `admin` matches `owner` + * everywhere except owner management, which {@link canManageOrganizationOwners} + * reserves for owners. + */ +export const ORGANIZATION_MANAGE_ROLES = ['owner', 'admin'] satisfies OrganizationRole[]; + +/** + * Roles that may manage organization billing. Admins are included because they + * hold the same authority as owners here. + */ +export const ORGANIZATION_BILLING_ROLES = [ + 'owner', + 'admin', + 'billing_manager', +] satisfies OrganizationRole[]; + +export function canManageOrganization(role: string | undefined): boolean { + return ORGANIZATION_MANAGE_ROLES.some(allowed => allowed === role); +} + +/** + * Owner management — granting the owner role, or acting on an existing owner's + * membership or pending owner invitation — is reserved for owners. Admins are + * excluded so they can neither appoint an owner nor strip the last one. + * + * Mirrors `assertOwnerAuthority` in + * apps/web/src/routers/organizations/organization-members-router.ts; keep the + * two in sync so the UI does not offer actions the server rejects. + */ +export function canManageOrganizationOwners(role: string | undefined): boolean { + return role === 'owner'; +} + +/** + * True for roles that may manage organization billing (owner, admin or * billing_manager). Ported from web's `canManageBilling` * (components/organizations/subscription/utils.ts) and mobile's * `isMoneyRole` (lib/hooks/use-organization-queries.ts) — both had the - * identical `role === 'owner' || role === 'billing_manager'` check. + * identical `role === 'owner' || role === 'billing_manager'` check; `admin` + * was added because admins hold the same authority as owners. */ export function canManageOrganizationBilling(role: string | undefined): boolean { - return role === 'owner' || role === 'billing_manager'; + return ORGANIZATION_BILLING_ROLES.some(allowed => allowed === role); } // No shared role-label map: web's getRoleLabel (organization-shared-utils.tsx) diff --git a/packages/app-shared/src/security-agent/settings.test.ts b/packages/app-shared/src/security-agent/settings.test.ts index b65975d51e9..67b4a0f0d8c 100644 --- a/packages/app-shared/src/security-agent/settings.test.ts +++ b/packages/app-shared/src/security-agent/settings.test.ts @@ -21,9 +21,10 @@ describe('Security Agent helpers', () => { ); }); - it('allows only personal, owner, and billing manager policy changes', () => { + it('allows only personal, owner, admin, and billing manager policy changes', () => { expect(canManageSecurityAgent('personal', undefined)).toBe(true); expect(canManageSecurityAgent('org_123', 'owner')).toBe(true); + expect(canManageSecurityAgent('org_123', 'admin')).toBe(true); expect(canManageSecurityAgent('org_123', 'billing_manager')).toBe(true); expect(canManageSecurityAgent('org_123', 'member')).toBe(false); }); diff --git a/packages/app-shared/src/security-agent/settings.ts b/packages/app-shared/src/security-agent/settings.ts index a2910eedd30..bd01ddc1ede 100644 --- a/packages/app-shared/src/security-agent/settings.ts +++ b/packages/app-shared/src/security-agent/settings.ts @@ -1,14 +1,13 @@ +import { canManageOrganizationBilling, type OrganizationRole } from '../organizations/roles'; + export const PERSONAL_SECURITY_SCOPE = 'personal'; export function isPersonalSecurityScope(scope: string): boolean { return scope === PERSONAL_SECURITY_SCOPE; } -export function canManageSecurityAgent( - scope: string, - role: 'owner' | 'billing_manager' | 'member' | undefined -): boolean { - return isPersonalSecurityScope(scope) || role === 'owner' || role === 'billing_manager'; +export function canManageSecurityAgent(scope: string, role: OrganizationRole | undefined): boolean { + return isPersonalSecurityScope(scope) || canManageOrganizationBilling(role); } export function getSecurityAgentAuditUrl(webBaseUrl: string, scope: string): string { diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 24dc00d1f4a..9fbeb59bef8 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -787,7 +787,7 @@ export type ContributorChampionTier = // --- Organization types --- -export type OrganizationRole = 'owner' | 'member' | 'billing_manager'; +export type OrganizationRole = 'owner' | 'admin' | 'member' | 'billing_manager'; export const OrganizationPlanSchema = z.enum(['teams', 'enterprise']);