From 64e92af8c5717df7809f5f780a7644bc181937b1 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 11:58:36 +0200 Subject: [PATCH 1/7] feat(web): add parent/child org scope to usage analytics On the organization usage page, parent-org owners/billing managers can now switch the Scope between My Usage, an All Organizations aggregate (parent + children), the parent org, and each child org. Adds a multi-org aggregate mode to the usage filters/SQL, a getScopeOrganizations endpoint, and generalizes resolveOrgUsers to span multiple orgs. --- .../FilterGeneratorPopover.tsx | 16 +- .../UsageAnalyticsDashboard.tsx | 178 +++++++++++------- .../usage-analytics/UsageAnalyticsSidebar.tsx | 60 ++++-- .../src/components/usage-analytics/hooks.ts | 23 ++- .../usage-analytics/useUsageDashboardState.ts | 30 +-- .../routers/usage-analytics-router.test.ts | 66 +++++++ .../web/src/routers/usage-analytics-router.ts | 149 ++++++++++++--- .../src/routers/usage-analytics-schemas.ts | 7 + 8 files changed, 408 insertions(+), 121 deletions(-) diff --git a/apps/web/src/components/usage-analytics/FilterGeneratorPopover.tsx b/apps/web/src/components/usage-analytics/FilterGeneratorPopover.tsx index 60cfd3868b..b75176d190 100644 --- a/apps/web/src/components/usage-analytics/FilterGeneratorPopover.tsx +++ b/apps/web/src/components/usage-analytics/FilterGeneratorPopover.tsx @@ -28,6 +28,8 @@ import type { Granularity } from './types'; type FilterGeneratorPopoverProps = { /** Query scope (to populate value suggestions). */ organizationId: string | null; + /** Multi-org aggregate scope (parent + children), when viewing all orgs. */ + organizationIds: string[] | null; dateRange: DateRange; personalScope: PersonalScope; /** @@ -59,6 +61,7 @@ const DIMENSIONS_ORG: Dimension[] = ['feature', 'model', 'mode', 'user', 'provid export function FilterGeneratorPopover({ organizationId, + organizationIds, dateRange, personalScope, canFilterByUser, @@ -76,6 +79,9 @@ export function FilterGeneratorPopover({ const dimensionOptions = canFilterByUser ? DIMENSIONS_ORG : DIMENSIONS_PERSONAL; + const scopeOrganizationIds = + organizationIds && organizationIds.length > 0 ? organizationIds : undefined; + const trpc = useTRPC(); const { data: breakdown, isLoading } = useQuery({ ...trpc.usageAnalytics.getBreakdown.queryOptions({ @@ -83,7 +89,9 @@ export function FilterGeneratorPopover({ endDate: dateRange.endDate, granularity, costSource, - organizationId: organizationId ?? undefined, + // In multi-org aggregate mode the server keys off `organizationIds`. + organizationId: scopeOrganizationIds ? undefined : (organizationId ?? undefined), + organizationIds: scopeOrganizationIds, personalScope, viewAs, dimension, @@ -101,8 +109,12 @@ export function FilterGeneratorPopover({ () => (dimension === 'user' ? suggestionKeys : []), [dimension, suggestionKeys] ); + const resolutionOrgIds = useMemo( + () => scopeOrganizationIds ?? (organizationId ? [organizationId] : []), + [scopeOrganizationIds, organizationId] + ); const { data: userSuggestionResolution, isLoading: userSuggestionResolutionLoading } = - useResolveOrgUsers(organizationId, userSuggestionIds); + useResolveOrgUsers(resolutionOrgIds, userSuggestionIds); const isResolvingUserSuggestions = dimension === 'user' && userSuggestionIds.length > 0 && userSuggestionResolutionLoading; const resolvedUsersById = useMemo( diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index b59e631b07..d4984f15d4 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -1,6 +1,6 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { skipToken, useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -42,7 +42,11 @@ import { type UsageFilters, type ViewAs, } from './hooks'; -import { useUsageDashboardState } from './useUsageDashboardState'; +import { + ORG_SCOPE_ALL_ORGS, + ORG_SCOPE_SELF, + useUsageDashboardState, +} from './useUsageDashboardState'; import { DIMENSION_LABELS, type CostSource, @@ -125,10 +129,15 @@ export function UsageAnalyticsDashboard({ filters, groupBy, personalView, - viewAs, + orgScope, usageView, } = state; const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const isOrgContext = context === 'organization'; + // Owners/billing_managers are the only roles that may view org-wide usage and + // (via inheritance) child-org usage, so only they get the expanded scope list. + const isOrgAdmin = + isOrgContext && (callerRole === 'owner' || callerRole === 'billing_manager'); const hasEnterpriseUsageViews = context === 'organization' && organizationPlan === 'enterprise'; const showDetailedUsage = !hasEnterpriseUsageViews || usageView === 'ai-usage'; @@ -141,6 +150,16 @@ export function UsageAnalyticsDashboard({ enabled: context === 'personal', }); + // Parent/child hierarchy for the org-context Scope selector. Only fetched for + // owners/billing_managers; members never see the expanded scope list. + const { data: scopeOrgs } = useQuery( + trpc.usageAnalytics.getScopeOrganizations.queryOptions( + isOrgAdmin && organizationId ? { organizationId } : skipToken + ) + ); + const childOrganizations = useMemo(() => scopeOrgs?.children ?? [], [scopeOrgs]); + const isParentOrg = childOrganizations.length > 0; + const dateRange = useMemo(() => periodToDateRange(period), [period]); const granularityOptions = useMemo(() => granularityOptionsForPeriod(period), [period]); @@ -151,40 +170,70 @@ export function UsageAnalyticsDashboard({ [setState] ); - const effectiveOrgId = - context === 'organization' + // ---- Effective query scope ---------------------------------------------- + // Personal context: the org (if any) chosen in the personal Scope dropdown. + const personalEffectiveOrgId = + personalView !== PERSONAL_VIEW_PERSONAL_ONLY && personalView !== PERSONAL_VIEW_ALL_USAGE + ? personalView + : null; + + // The set of scope values the caller is allowed to pick in org context: + // 'self', the page org (org-wide), each child org, and the all-orgs aggregate. + const validOrgScopeValues = useMemo(() => { + const values = new Set([ORG_SCOPE_SELF]); + if (organizationId) values.add(organizationId); + for (const child of childOrganizations) values.add(child.organizationId); + if (childOrganizations.length > 0) values.add(ORG_SCOPE_ALL_ORGS); + return values; + }, [organizationId, childOrganizations]); + + // Clamp the stored scope to something the caller may actually see. Non-admins + // and any stale/unknown scope (e.g. a deep link to a sibling org) collapse to + // "My Usage". The server independently enforces access regardless. + const resolvedOrgScope = + isOrgAdmin && validOrgScopeValues.has(orgScope) ? orgScope : ORG_SCOPE_SELF; + const isAllOrgsScope = isOrgContext && resolvedOrgScope === ORG_SCOPE_ALL_ORGS; + const isSelfOrgScope = resolvedOrgScope === ORG_SCOPE_SELF; + + // Single org targeted by a query: the page org for 'self', the selected org + // for a specific pick, and none for the all-orgs aggregate. + const orgContextOrgId: string | null = isAllOrgsScope + ? null + : isSelfOrgScope ? organizationId - : personalView !== PERSONAL_VIEW_PERSONAL_ONLY && personalView !== PERSONAL_VIEW_ALL_USAGE - ? personalView - : null; + : resolvedOrgScope; + + const effectiveOrgId: string | null = isOrgContext ? orgContextOrgId : personalEffectiveOrgId; + + // Org ids aggregated by the "All Organizations" scope (parent + children). + const effectiveOrganizationIds = useMemo(() => { + if (!isAllOrgsScope) return null; + const ids = new Set(); + if (organizationId) ids.add(organizationId); + for (const child of childOrganizations) ids.add(child.organizationId); + return Array.from(ids); + }, [isAllOrgsScope, organizationId, childOrganizations]); + const effectivePersonalScope: 'personal-only' | 'include-orgs' = - context === 'organization' || personalView === PERSONAL_VIEW_ALL_USAGE - ? 'include-orgs' - : 'personal-only'; + isOrgContext || personalView === PERSONAL_VIEW_ALL_USAGE ? 'include-orgs' : 'personal-only'; + + // Any non-self org scope (a specific org or the all-orgs aggregate) is + // org-wide. Personal context is always 'self'. + const effectiveViewAs: ViewAs = isOrgContext && !isSelfOrgScope ? 'org-wide' : 'self'; // Role in the effective org drives whether the caller may see all users. // - Organization context: prop `callerRole` from the server layout. // - Personal context with an org selected: look it up via organizations.list. - // - Personal context with no org selected: no role; toggle hidden. const roleForEffectiveOrg: OrganizationRole | undefined = useMemo(() => { - if (context === 'organization') return callerRole; - if (!effectiveOrgId) return undefined; - const match = organizations?.find(o => o.organizationId === effectiveOrgId); - return match?.role; - }, [context, callerRole, effectiveOrgId, organizations]); + if (isOrgContext) return callerRole; + if (!personalEffectiveOrgId) return undefined; + return organizations?.find(o => o.organizationId === personalEffectiveOrgId)?.role; + }, [isOrgContext, callerRole, personalEffectiveOrgId, organizations]); - const canViewAllOrgUsers = - !!effectiveOrgId && - (roleForEffectiveOrg === 'owner' || roleForEffectiveOrg === 'billing_manager'); - - // Per plan: the view-as toggle is hidden on the personal page entirely. - // Personal page users picking an org always get "my usage in that org". - const showViewAsSelector = context === 'organization' && canViewAllOrgUsers; - - // Effective viewAs: only honor the toggle when it's allowed + shown. - // Server still enforces this; client-side gating avoids sending requests - // that would be rejected. - const effectiveViewAs: ViewAs = showViewAsSelector && viewAs === 'org-wide' ? 'org-wide' : 'self'; + const canViewAllOrgUsers = isOrgContext + ? isOrgAdmin + : !!personalEffectiveOrgId && + (roleForEffectiveOrg === 'owner' || roleForEffectiveOrg === 'billing_manager'); /** * Whether the current effective view includes data from multiple users. @@ -194,43 +243,42 @@ export function UsageAnalyticsDashboard({ */ const isOrgWideView = canViewAllOrgUsers && effectiveViewAs === 'org-wide'; - // Reset viewAs to 'self' whenever the effective org changes (e.g. personal - // user switches org in the Scope dropdown). Only fires on actual org changes - // to avoid a redundant state update on initial mount. - const prevEffectiveOrgId = useRef(effectiveOrgId); - useEffect(() => { - if (prevEffectiveOrgId.current !== effectiveOrgId) { - setState({ viewAs: 'self' }); - prevEffectiveOrgId.current = effectiveOrgId; + // Orgs to resolve user ids against for display labels. For the all-orgs + // aggregate this spans the parent and its children. + const userResolutionOrgIds = useMemo(() => { + if (effectiveOrganizationIds && effectiveOrganizationIds.length > 0) { + return effectiveOrganizationIds; } - }, [effectiveOrgId, setState]); - - // When the view collapses to a single user ('self'), drop any stale - // user-dimension state that no longer makes sense: - // - Reset `groupBy: 'user'` to 'none' so the chart splits by time only. - // - Clear user include/exclude filters (the server would otherwise reject - // self-scope requests carrying userIds referring to someone else). + return effectiveOrgId ? [effectiveOrgId] : []; + }, [effectiveOrganizationIds, effectiveOrgId]); + + // When the scope changes, drop user-dimension state that no longer applies: + // - 'self' has no other users, so reset `groupBy: 'user'` and clear user + // filters (the server rejects self-scope requests carrying others' ids). + // - Switching between orgs invalidates user filters that referenced the + // previous org's members. + const prevResolvedScope = useRef(resolvedOrgScope); useEffect(() => { - if (isOrgWideView) return; + const scopeChanged = prevResolvedScope.current !== resolvedOrgScope; + prevResolvedScope.current = resolvedOrgScope; + if (isOrgWideView && !scopeChanged) return; const updates: Partial['state']> = {}; - if (groupBy === 'user') updates.groupBy = 'none'; - if (filters.userIds.length > 0 || filters.excludedUserIds.length > 0) { + if (groupBy === 'user' && !isOrgWideView) updates.groupBy = 'none'; + if ( + (filters.userIds.length > 0 || filters.excludedUserIds.length > 0) && + (!isOrgWideView || scopeChanged) + ) { updates.filters = { ...filters, userIds: [], excludedUserIds: [] }; } if (Object.keys(updates).length > 0) { setState(updates); } - }, [isOrgWideView, groupBy, filters, setState]); - - const effectiveOrganizationName = useMemo(() => { - if (context === 'organization') return organizationName ?? null; - if (!effectiveOrgId) return null; - return organizations?.find(o => o.organizationId === effectiveOrgId)?.organizationName ?? null; - }, [context, organizationName, effectiveOrgId, organizations]); + }, [resolvedOrgScope, isOrgWideView, groupBy, filters, setState]); const commonArgs = useMemo( () => ({ organizationId: effectiveOrgId, + organizationIds: effectiveOrganizationIds, dateRange, granularity, costSource, @@ -240,6 +288,7 @@ export function UsageAnalyticsDashboard({ }), [ effectiveOrgId, + effectiveOrganizationIds, dateRange, granularity, costSource, @@ -306,7 +355,7 @@ export function UsageAnalyticsDashboard({ // resolve labels correctly; today that path is hidden in the UI, but the // resolver should not depend on UI gating. const userIds = useMemo(() => { - if (!effectiveOrgId) return []; + if (userResolutionOrgIds.length === 0) return []; const fromBreakdown = userBreakdown?.breakdown.map(b => b.key) ?? []; const fromFilters = [...filters.userIds, ...filters.excludedUserIds]; const fromTable = @@ -315,8 +364,8 @@ export function UsageAnalyticsDashboard({ return userId ? [userId] : []; }) ?? []; return Array.from(new Set([...fromBreakdown, ...fromFilters, ...fromTable])); - }, [userBreakdown, effectiveOrgId, filters.userIds, filters.excludedUserIds, tableData]); - const { data: userResolution } = useResolveOrgUsers(effectiveOrgId, userIds); + }, [userBreakdown, userResolutionOrgIds, filters.userIds, filters.excludedUserIds, tableData]); + const { data: userResolution } = useResolveOrgUsers(userResolutionOrgIds, userIds); const userLabelFor = useCallback( (value: string) => { const match = userResolution?.users.find(u => u.id === value); @@ -331,13 +380,13 @@ export function UsageAnalyticsDashboard({ const labelForDimensionValue = useCallback( (dim: Dimension, value: string): string => { - if (dim === 'user' && effectiveOrgId) return userLabelFor(value); + if (dim === 'user' && userResolutionOrgIds.length > 0) return userLabelFor(value); if (dim === 'feature') return featureLabelFor(value); if (dim === 'mode') return modeLabelFor(value); if (dim === 'project') return projectLabelFor(value); return value; }, - [effectiveOrgId, userLabelFor, featureLabelFor, modeLabelFor, projectLabelFor] + [userResolutionOrgIds, userLabelFor, featureLabelFor, modeLabelFor, projectLabelFor] ); const activeFilters = useMemo((): ActiveFilter[] => { @@ -493,22 +542,25 @@ export function UsageAnalyticsDashboard({ }); }, [tableData, tableGroupBy, granularity, period, costSource, labelForDimensionValue]); - const isOrgContext = context === 'organization'; - const sidebar = ( setState({ personalView: v })} organizations={organizations ?? []} viewAs={effectiveViewAs} - onViewAsChange={(v: ViewAs) => setState({ viewAs: v })} + orgScope={resolvedOrgScope} + onOrgScopeChange={(v: string) => setState({ orgScope: v })} + pageOrganizationId={organizationId} + pageOrganizationName={organizationName ?? null} + childOrganizations={childOrganizations} + isParentOrg={isParentOrg} canViewAllOrgUsers={canViewAllOrgUsers} isOrgWideView={isOrgWideView} - effectiveOrganizationName={effectiveOrganizationName} period={period} onPeriodChange={handlePeriodChange} granularity={granularity} diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsSidebar.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsSidebar.tsx index cf7e82c55f..914706aefd 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsSidebar.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsSidebar.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/select'; import { X } from 'lucide-react'; import { FilterGeneratorPopover } from './FilterGeneratorPopover'; +import { ORG_SCOPE_ALL_ORGS, ORG_SCOPE_SELF } from './useUsageDashboardState'; import { COST_SOURCE_LABELS, DIMENSION_LABELS, @@ -58,6 +59,8 @@ export type ViewAs = 'self' | 'org-wide'; type UsageAnalyticsSidebarProps = { context: 'personal' | 'organization'; organizationId: string | null; + /** Multi-org aggregate scope (parent + children) for value-suggestion queries. */ + organizationIds: string[] | null; dateRange: DateRange; personalScope: PersonalScope; @@ -66,12 +69,23 @@ type UsageAnalyticsSidebarProps = { onPersonalViewChange: (value: PersonalView) => void; organizations: OrganizationSummary[]; - // View-as toggle (org context, role-gated) + // Scope (org context, role-gated) viewAs: ViewAs; - onViewAsChange: (value: ViewAs) => void; /** - * Whether the caller's role lets them flip to the org-wide view. Drives the - * visibility of the "My Usage / Entire Organization" toggle only. + * Org-context scope selection: `'self'`, `'all-orgs'`, or an organization id. + */ + orgScope: string; + onOrgScopeChange: (value: string) => void; + /** The org whose usage page this is (the parent, when it has children). */ + pageOrganizationId: string | null; + pageOrganizationName: string | null; + /** Direct child orgs, when the page org is a parent. */ + childOrganizations: OrganizationSummary[]; + /** True when the page org has at least one child org. */ + isParentOrg: boolean; + /** + * Whether the caller's role lets them view org-wide usage. Drives the + * visibility of the org-context Scope selector. */ canViewAllOrgUsers: boolean; /** @@ -80,8 +94,6 @@ type UsageAnalyticsSidebarProps = { * Becomes false when the caller is seeing only their own usage. */ isOrgWideView: boolean; - /** Name used in the "Entire {orgName}" label. Falls back to "Organization". */ - effectiveOrganizationName: string | null; // Period period: PeriodOption; @@ -115,16 +127,21 @@ type UsageAnalyticsSidebarProps = { export function UsageAnalyticsSidebar({ context, organizationId, + organizationIds, dateRange, personalScope, personalView, onPersonalViewChange, organizations, viewAs, - onViewAsChange, + orgScope, + onOrgScopeChange, + pageOrganizationId, + pageOrganizationName, + childOrganizations, + isParentOrg, canViewAllOrgUsers, isOrgWideView, - effectiveOrganizationName, period, onPeriodChange, granularity, @@ -146,12 +163,10 @@ export function UsageAnalyticsSidebar({ }: UsageAnalyticsSidebarProps) { const isOrgContext = context === 'organization'; const showPersonalViewSelector = context === 'personal' && organizations.length > 0; - // Per plan: the view-as toggle is only rendered on the organization usage - // page and only when the caller has permission to see all org users. - const showViewAsSelector = isOrgContext && canViewAllOrgUsers; - const entireOrgLabel = effectiveOrganizationName - ? `${effectiveOrganizationName}` - : 'Organization'; + // The org-context Scope selector is only rendered on the organization usage + // page and only when the caller may view org-wide usage. + const showOrgScopeSelector = isOrgContext && canViewAllOrgUsers && !!pageOrganizationId; + const pageOrgLabel = pageOrganizationName ?? 'Organization'; const groupByOptions: (Dimension | 'none')[] = useMemo(() => { const opts: (Dimension | 'none')[] = [ @@ -194,15 +209,23 @@ export function UsageAnalyticsSidebar({ )} - {showViewAsSelector && ( + {showOrgScopeSelector && pageOrganizationId && (
- - My Usage - {entireOrgLabel} + My Usage + {isParentOrg && ( + All Organizations + )} + {pageOrgLabel} + {childOrganizations.map(child => ( + + {child.organizationName} + + ))}
@@ -304,6 +327,7 @@ export function UsageAnalyticsSidebar({
0 ? args.organizationIds : undefined; return { startDate: args.dateRange.startDate, endDate: args.dateRange.endDate, granularity: args.granularity, costSource: args.costSource, - organizationId: args.organizationId ?? undefined, + // In multi-org aggregate mode the server keys off `organizationIds`; don't + // also send a single `organizationId` (they are mutually exclusive). + organizationId: organizationIds ? undefined : (args.organizationId ?? undefined), + organizationIds, personalScope: args.personalScope ?? 'personal-only', viewAs: args.viewAs ?? 'self', ...pickFiltersInput(args.filters), @@ -224,16 +235,22 @@ export function useUsageTable( }); } -export function useResolveOrgUsers(organizationId: string | null, userIds: string[]) { +export function useResolveOrgUsers(organizationIds: string[], userIds: string[]) { const trpc = useTRPC(); const dedupedIds = useMemo(() => Array.from(new Set(userIds)).sort(), [userIds]); + const dedupedOrgIds = useMemo( + () => Array.from(new Set(organizationIds)).sort(), + [organizationIds] + ); // Pass the real input only when we have a legitimate org scope and a // non-empty id list. Using `skipToken` (instead of a placeholder UUID + // `enabled: false`) guarantees the server never receives a sentinel id // even if future call sites drop the `enabled` gate. return useQuery( trpc.usageAnalytics.resolveOrgUsers.queryOptions( - organizationId && dedupedIds.length > 0 ? { organizationId, userIds: dedupedIds } : skipToken + dedupedOrgIds.length > 0 && dedupedIds.length > 0 + ? { organizationIds: dedupedOrgIds, userIds: dedupedIds } + : skipToken ) ); } diff --git a/apps/web/src/components/usage-analytics/useUsageDashboardState.ts b/apps/web/src/components/usage-analytics/useUsageDashboardState.ts index 56919d1fdc..53c245ff46 100644 --- a/apps/web/src/components/usage-analytics/useUsageDashboardState.ts +++ b/apps/web/src/components/usage-analytics/useUsageDashboardState.ts @@ -20,10 +20,20 @@ export type DashboardState = { filters: UsageFilters; groupBy: Dimension | 'none'; personalView: string; - viewAs: 'self' | 'org-wide'; + /** + * Org-context scope selection. One of: + * - `'self'` — the caller's own usage in the page org (default) + * - `'all-orgs'` — org-wide aggregate across a parent org + its children + * - any other string — an organization id, viewed org-wide + * Only meaningful in the organization context; ignored in personal context. + */ + orgScope: string; usageView: OrganizationUsageView; }; +export const ORG_SCOPE_SELF = 'self'; +export const ORG_SCOPE_ALL_ORGS = 'all-orgs'; + const VALID_PERIODS: PeriodOption[] = ['today', 'yesterday', '7d', '30d', '1y']; const VALID_GRANULARITIES: Granularity[] = ['hour', 'day', 'week', 'month']; const VALID_DIMENSIONS: Dimension[] = ['feature', 'model', 'mode', 'user', 'provider', 'project']; @@ -199,8 +209,7 @@ export function useUsageDashboardState(defaultState?: Partial): const personalView = params.get('personalView') ?? defaultState?.personalView ?? 'personal-only'; - const viewAsRaw = params.get('viewAs'); - const viewAs = viewAsRaw === 'org-wide' ? 'org-wide' : (defaultState?.viewAs ?? 'self'); + const orgScope = params.get('scope') ?? defaultState?.orgScope ?? ORG_SCOPE_SELF; const usageViewRaw = params.get('view'); const usageView = VALID_USAGE_VIEWS.includes(usageViewRaw as OrganizationUsageView) @@ -217,7 +226,7 @@ export function useUsageDashboardState(defaultState?: Partial): filters, groupBy, personalView, - viewAs, + orgScope, usageView, }; }); @@ -257,8 +266,7 @@ export function useUsageDashboardState(defaultState?: Partial): ? (groupByRaw as Dimension | 'none') : state.groupBy; const personalView = params.get('personalView') ?? state.personalView; - const viewAsRaw = params.get('viewAs'); - const viewAs = viewAsRaw === 'org-wide' ? 'org-wide' : state.viewAs; + const orgScope = params.get('scope') ?? state.orgScope; const usageViewRaw = params.get('view'); const usageView = VALID_USAGE_VIEWS.includes(usageViewRaw as OrganizationUsageView) ? (usageViewRaw as OrganizationUsageView) @@ -273,7 +281,7 @@ export function useUsageDashboardState(defaultState?: Partial): filters, groupBy, personalView, - viewAs, + orgScope, usageView, }); @@ -293,7 +301,7 @@ export function useUsageDashboardState(defaultState?: Partial): state.chartMetric, state.groupBy, state.personalView, - state.viewAs, + state.orgScope, state.usageView, ]); @@ -329,10 +337,10 @@ export function useUsageDashboardState(defaultState?: Partial): params.delete('personalView'); } - if (state.viewAs === 'org-wide') { - params.set('viewAs', 'org-wide'); + if (state.orgScope && state.orgScope !== ORG_SCOPE_SELF) { + params.set('scope', state.orgScope); } else { - params.delete('viewAs'); + params.delete('scope'); } if (state.usageView === 'overview') { diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index efe2113446..a8aa1633f3 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -3,6 +3,8 @@ jest.mock('@/lib/redis', () => ({ redisClient: {} })); import { CostSourceSchema, UsageAnalyticsFiltersSchema, + WhereBuilder, + buildScopeConditions, costColumnFor, costSumExprSql, } from './usage-analytics-router'; @@ -13,6 +15,18 @@ const baseFilters = { granularity: 'day' as const, }; +const CTX_USER = 'user-1'; +const PARENT_ORG = '11111111-1111-4111-8111-111111111111'; +const CHILD_ORG_A = '22222222-2222-4222-8222-222222222222'; +const CHILD_ORG_B = '33333333-3333-4333-8333-333333333333'; + +function scopeSql(rawFilters: Record) { + const filters = UsageAnalyticsFiltersSchema.parse({ ...baseFilters, ...rawFilters }); + const where = new WhereBuilder(); + buildScopeConditions(where, filters, CTX_USER); + return { sql: where.sql(), bindings: where.bindings.map(b => b.value) }; +} + describe('usage analytics cost source', () => { it('defaults to billable cost for existing clients', () => { expect(UsageAnalyticsFiltersSchema.parse(baseFilters).costSource).toBe('cost'); @@ -34,3 +48,55 @@ describe('usage analytics cost source', () => { ).toBe(false); }); }); + +describe('usage analytics scope conditions', () => { + it('pins a single org to the caller in self view', () => { + const { sql, bindings } = scopeSql({ organizationId: PARENT_ORG, viewAs: 'self' }); + expect(sql).toContain('organization_id = ?'); + expect(sql).toContain('kilo_user_id = ?'); + expect(bindings).toEqual([PARENT_ORG, CTX_USER]); + }); + + it('does not pin to the caller in org-wide view', () => { + const { sql, bindings } = scopeSql({ organizationId: PARENT_ORG, viewAs: 'org-wide' }); + expect(sql).toContain('organization_id = ?'); + expect(sql).not.toContain('kilo_user_id'); + expect(bindings).toEqual([PARENT_ORG]); + }); + + it('aggregates org-wide across all orgs when organizationIds is set', () => { + const { sql, bindings } = scopeSql({ + organizationIds: [PARENT_ORG, CHILD_ORG_A, CHILD_ORG_B], + }); + expect(sql).toContain('organization_id IN (?, ?, ?)'); + expect(sql).not.toContain('kilo_user_id'); + expect(bindings).toEqual([PARENT_ORG, CHILD_ORG_A, CHILD_ORG_B]); + }); + + it('honors explicit user filters in the all-orgs aggregate', () => { + const { sql, bindings } = scopeSql({ + organizationIds: [PARENT_ORG, CHILD_ORG_A], + userIds: [CTX_USER], + }); + expect(sql).toContain('organization_id IN (?, ?)'); + expect(sql).toContain('kilo_user_id IN (?)'); + expect(bindings).toEqual([PARENT_ORG, CHILD_ORG_A, CTX_USER]); + }); + + it('takes precedence over a single organizationId', () => { + const { sql, bindings } = scopeSql({ + organizationId: CHILD_ORG_B, + organizationIds: [PARENT_ORG, CHILD_ORG_A], + }); + expect(sql).toContain('organization_id IN (?, ?)'); + expect(bindings).toEqual([PARENT_ORG, CHILD_ORG_A]); + }); + + it('falls back to personal scope with no org', () => { + const { sql, bindings } = scopeSql({}); + expect(sql).toContain('kilo_user_id = ?'); + expect(sql).toContain("organization_id = ?"); + // personal-only pins kilo_user_id to caller and org to the empty-string sentinel + expect(bindings).toEqual([CTX_USER, '']); + }); +}); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 35858b2b13..081fb7e424 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; import * as z from 'zod'; -import { and, eq, inArray, or } from 'drizzle-orm'; +import { and, asc, eq, inArray, or } from 'drizzle-orm'; import { baseProcedure, createTRPCRouter, type TRPCContext } from '@/lib/trpc/init'; import { readDb } from '@/lib/drizzle'; import { getEnvVariable } from '@/lib/dotenvx'; @@ -9,7 +9,12 @@ import { resolveSnowflakeConfig, type SnowflakeBinding, } from '@/lib/snowflake'; -import { kilocode_users, organization_memberships, user_auth_provider } from '@kilocode/db/schema'; +import { + kilocode_users, + organization_memberships, + organizations, + user_auth_provider, +} from '@kilocode/db/schema'; import type { AuthProviderId } from '@kilocode/db/schema-types'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { @@ -137,7 +142,7 @@ function ceilIsoToUtcMonthExclusive(iso: string): string { * Accumulates SQL WHERE clauses with positional `?` bindings. * Callers push conditions in any order; `sql()` joins them with AND. */ -class WhereBuilder { +export class WhereBuilder { readonly clauses: string[] = []; readonly bindings: SnowflakeBinding[] = []; @@ -195,8 +200,27 @@ class WhereBuilder { // Authorization // --------------------------------------------------------------------------- +/** True when the filters target one or more organizations (vs personal usage). */ +function isOrgScope(filters: UsageAnalyticsFilters): boolean { + return Boolean(filters.organizationId) || (filters.organizationIds?.length ?? 0) > 0; +} + async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilters): Promise { const userId = ctx.user.id; + + // Multi-org aggregate ("All Organizations"): always org-wide, and the caller + // must be owner/billing_manager of every org in the list. A parent owner has + // inherited owner/billing access to children, so this passes for the parent + // plus all of its children while rejecting any org they cannot administer. + if (filters.organizationIds && filters.organizationIds.length > 0) { + await Promise.all( + filters.organizationIds.map(orgId => + ensureOrganizationAccess(ctx, orgId, ['owner', 'billing_manager']) + ) + ); + return; + } + if (filters.organizationId) { const requiredRoles = filters.viewAs === 'org-wide' ? (['owner', 'billing_manager'] as const) : undefined; @@ -256,11 +280,23 @@ function buildDateConditions( } } -function buildScopeConditions( +export function buildScopeConditions( where: WhereBuilder, filters: UsageAnalyticsFilters, ctxUserId: string ): void { + if (filters.organizationIds && filters.organizationIds.length > 0) { + // Aggregate across the parent org and its children. Always org-wide, so + // honor any explicit user include/exclude filters but never pin to self. + where.addIn('organization_id', filters.organizationIds); + if (filters.userIds && filters.userIds.length > 0) { + where.addIn('kilo_user_id', filters.userIds); + } + if (filters.excludedUserIds && filters.excludedUserIds.length > 0) { + where.addNotIn('kilo_user_id', filters.excludedUserIds); + } + return; + } if (filters.organizationId) { where.addEq('organization_id', filters.organizationId); if (filters.viewAs === 'self') { @@ -535,9 +571,10 @@ function toSafeNumber(value: unknown): number { // --------------------------------------------------------------------------- const MAX_USER_LABEL_LOOKUP_IDS = 1_000; +const MAX_USER_LABEL_LOOKUP_ORGS = 100; const UserListInputSchema = z.object({ - organizationId: z.uuid(), + organizationIds: z.array(z.uuid()).min(1).max(MAX_USER_LABEL_LOOKUP_ORGS), userIds: z.array(z.string()).max(MAX_USER_LABEL_LOOKUP_IDS), }); @@ -551,6 +588,26 @@ const UserListOutputSchema = z.object({ ), }); +// --------------------------------------------------------------------------- +// Scope organizations (org usage page Scope selector) +// --------------------------------------------------------------------------- + +const ScopeOrganizationsInputSchema = z.object({ + organizationId: z.uuid(), +}); + +const ScopeOrganizationSchema = z.object({ + organizationId: z.string(), + organizationName: z.string(), +}); + +const ScopeOrganizationsOutputSchema = z.object({ + organizationId: z.string(), + organizationName: z.string(), + /** Direct child organizations, sorted by name. Empty when not a parent org. */ + children: z.array(ScopeOrganizationSchema), +}); + function parseLegacyOAuthUserId( userId: string ): { provider: AuthProviderId; providerAccountId: string } | null { @@ -654,7 +711,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getSummary', queryLabel: `summary_${meta.tier}`, - scope: input.organizationId ? 'org' : 'user', + scope: isOrgScope(input) ? 'org' : 'user', period: `${input.startDate}/${input.endDate}`, }, signal => @@ -663,7 +720,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(isOrgScope(input) ? 'org' : 'user') / 1000 ), signal, }) @@ -761,7 +818,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getTimeseries', queryLabel: `timeseries_${meta.tier}${input.splitBy ? `_split_${input.splitBy}` : ''}`, - scope: input.organizationId ? 'org' : 'user', + scope: isOrgScope(input) ? 'org' : 'user', period: `${input.startDate}/${input.endDate}`, }, signal => @@ -770,7 +827,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(isOrgScope(input) ? 'org' : 'user') / 1000 ), signal, }) @@ -821,7 +878,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getBreakdown', queryLabel: `breakdown_${meta.tier}_by_${input.dimension}`, - scope: input.organizationId ? 'org' : 'user', + scope: isOrgScope(input) ? 'org' : 'user', period: `${input.startDate}/${input.endDate}`, }, signal => @@ -830,7 +887,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(isOrgScope(input) ? 'org' : 'user') / 1000 ), signal, }) @@ -913,7 +970,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getTable', queryLabel: `table_${meta.tier}_groupby_${requestedDims.join('+') || 'none'}`, - scope: input.organizationId ? 'org' : 'user', + scope: isOrgScope(input) ? 'org' : 'user', period: `${input.startDate}/${input.endDate}`, }, signal => @@ -922,7 +979,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(isOrgScope(input) ? 'org' : 'user') / 1000 ), signal, }) @@ -961,23 +1018,67 @@ export const usageAnalyticsRouter = createTRPCRouter({ }), /** - * Look up user names and emails for a set of user IDs that belong to an org. - * Used by the UI to decorate per-user breakdowns, filters, and table rows. + * Returns the org plus its direct child organizations, for the org usage + * page's Scope selector. Restricted to owner/billing_manager because that is + * who may view org-wide usage and (via inheritance) child-org usage. Members + * never see the expanded scope list, so they cannot enumerate children here. + */ + getScopeOrganizations: baseProcedure + .input(ScopeOrganizationsInputSchema) + .output(ScopeOrganizationsOutputSchema) + .query(async ({ input, ctx }) => { + await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + + const [org] = await readDb + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1); + + if (!org) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found' }); + } + + const children = await readDb + .select({ id: organizations.id, name: organizations.name }) + .from(organizations) + .where(eq(organizations.parent_organization_id, input.organizationId)) + .orderBy(asc(organizations.name)); + + return { + organizationId: org.id, + organizationName: org.name, + children: children.map(child => ({ + organizationId: child.id, + organizationName: child.name, + })), + }; + }), + + /** + * Look up user names and emails for a set of user IDs that belong to the + * given orgs. Used by the UI to decorate per-user breakdowns, filters, and + * table rows — including the multi-org "All Organizations" aggregate view, + * where a parent owner resolves users across the parent and its children. * - * Only returns users that are active or invited members of `organizationId` - * to prevent callers from enumerating arbitrary kilocode_users PII. + * Only returns users that are members of one of `organizationIds` to prevent + * callers from enumerating arbitrary kilocode_users PII. * - * Members (role != 'owner' | 'billing_manager') can only resolve their own - * id — they have no legitimate need to see other members' name/email from - * this endpoint. + * Callers who are not owner/billing_manager of *every* requested org can only + * resolve their own id — they have no legitimate need to see other members' + * name/email from this endpoint. */ resolveOrgUsers: baseProcedure .input(UserListInputSchema) .output(UserListOutputSchema) .query(async ({ input, ctx }) => { - const role = await ensureOrganizationAccess(ctx, input.organizationId); + const roles = await Promise.all( + input.organizationIds.map(orgId => ensureOrganizationAccess(ctx, orgId)) + ); - const canSeeAllMembers = role === 'owner' || role === 'billing_manager'; + const canSeeAllMembers = roles.every( + role => role === 'owner' || role === 'billing_manager' + ); const allowedIds = canSeeAllMembers ? input.userIds : input.userIds.filter(id => id === ctx.user.id); @@ -995,7 +1096,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ organization_memberships, and( eq(organization_memberships.kilo_user_id, kilocode_users.id), - eq(organization_memberships.organization_id, input.organizationId) + inArray(organization_memberships.organization_id, input.organizationIds) ) ) .where(inArray(kilocode_users.id, allowedIds)); @@ -1047,7 +1148,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ organization_memberships, and( eq(organization_memberships.kilo_user_id, user_auth_provider.kilo_user_id), - eq(organization_memberships.organization_id, input.organizationId) + inArray(organization_memberships.organization_id, input.organizationIds) ) ) .where(legacyWhere); diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts index 0c38fb1f06..98fe8c50e9 100644 --- a/apps/web/src/routers/usage-analytics-schemas.ts +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -31,6 +31,13 @@ const FiltersShape = { granularity: GranularitySchema, costSource: CostSourceSchema.default('cost'), organizationId: z.uuid().optional(), + /** + * Aggregate usage across multiple organizations (a parent org plus its + * children). When set and non-empty, this takes precedence over + * `organizationId` and is always treated as an org-wide view. The caller must + * have owner/billing_manager access to every listed org. + */ + organizationIds: z.array(z.uuid()).optional(), personalScope: z.enum(['personal-only', 'include-orgs']).default('personal-only'), viewAs: z.enum(['self', 'org-wide']).default('self'), features: z.array(z.string()).optional(), From ec36fcd1565b73d95a1d411b009965a5e0abe0cb Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 13:27:28 +0200 Subject: [PATCH 2/7] fix(web): harden org usage scope (deep links, deleted children) - Honor a deep-linked org scope while getScopeOrganizations is still loading so the cleanup effect no longer wipes grouping/user-filter state before validation runs. - Exclude soft-deleted child orgs from the scope list and the All Organizations aggregate. --- .../usage-analytics/UsageAnalyticsDashboard.tsx | 13 +++++++++++-- apps/web/src/routers/usage-analytics-router.ts | 13 ++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index d4984f15d4..f507d595c6 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -190,8 +190,17 @@ export function UsageAnalyticsDashboard({ // Clamp the stored scope to something the caller may actually see. Non-admins // and any stale/unknown scope (e.g. a deep link to a sibling org) collapse to // "My Usage". The server independently enforces access regardless. - const resolvedOrgScope = - isOrgAdmin && validOrgScopeValues.has(orgScope) ? orgScope : ORG_SCOPE_SELF; + // + // While the scope list is still loading we optimistically honor the URL scope + // rather than clamp: otherwise a deep link like `?scope=&group=user` + // would momentarily resolve to 'self', and the cleanup effect below would wipe + // (and persist) the deep-linked grouping/user filters before validation runs. + const scopeListPending = isOrgAdmin && !!organizationId && !scopeOrgs; + const resolvedOrgScope = !isOrgAdmin + ? ORG_SCOPE_SELF + : scopeListPending || validOrgScopeValues.has(orgScope) + ? orgScope + : ORG_SCOPE_SELF; const isAllOrgsScope = isOrgContext && resolvedOrgScope === ORG_SCOPE_ALL_ORGS; const isSelfOrgScope = resolvedOrgScope === ORG_SCOPE_SELF; diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 081fb7e424..e5ebb270cf 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; import * as z from 'zod'; -import { and, asc, eq, inArray, or } from 'drizzle-orm'; +import { and, asc, eq, inArray, isNull, or } from 'drizzle-orm'; import { baseProcedure, createTRPCRouter, type TRPCContext } from '@/lib/trpc/init'; import { readDb } from '@/lib/drizzle'; import { getEnvVariable } from '@/lib/dotenvx'; @@ -1032,17 +1032,24 @@ export const usageAnalyticsRouter = createTRPCRouter({ const [org] = await readDb .select({ id: organizations.id, name: organizations.name }) .from(organizations) - .where(eq(organizations.id, input.organizationId)) + .where(and(eq(organizations.id, input.organizationId), isNull(organizations.deleted_at))) .limit(1); if (!org) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found' }); } + // Exclude soft-deleted children so they never appear in the scope list or + // get folded into the All Organizations aggregate. const children = await readDb .select({ id: organizations.id, name: organizations.name }) .from(organizations) - .where(eq(organizations.parent_organization_id, input.organizationId)) + .where( + and( + eq(organizations.parent_organization_id, input.organizationId), + isNull(organizations.deleted_at) + ) + ) .orderBy(asc(organizations.name)); return { From 3b2c8a0c67b639d8c744223ef60c11ff991870fb Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 13:42:18 +0200 Subject: [PATCH 3/7] fix(web): cap organizationIds to bound auth fan-out The caller-controlled organizationIds filter drives per-org authorization queries and the Snowflake IN clause, so bound it at the API boundary (MAX_SCOPE_ORGANIZATION_IDS) to prevent resource exhaustion. --- .../routers/usage-analytics-router.test.ts | 21 +++++++++++++++++++ .../web/src/routers/usage-analytics-router.ts | 1 + .../src/routers/usage-analytics-schemas.ts | 9 +++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index a8aa1633f3..92a101b631 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -2,6 +2,7 @@ jest.mock('@/lib/redis', () => ({ redisClient: {} })); import { CostSourceSchema, + MAX_SCOPE_ORGANIZATION_IDS, UsageAnalyticsFiltersSchema, WhereBuilder, buildScopeConditions, @@ -99,4 +100,24 @@ describe('usage analytics scope conditions', () => { // personal-only pins kilo_user_id to caller and org to the empty-string sentinel expect(bindings).toEqual([CTX_USER, '']); }); + + it('caps organizationIds at the boundary to bound auth fan-out', () => { + const makeIds = (n: number) => + Array.from( + { length: n }, + (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}` + ); + expect( + UsageAnalyticsFiltersSchema.safeParse({ + ...baseFilters, + organizationIds: makeIds(MAX_SCOPE_ORGANIZATION_IDS), + }).success + ).toBe(true); + expect( + UsageAnalyticsFiltersSchema.safeParse({ + ...baseFilters, + organizationIds: makeIds(MAX_SCOPE_ORGANIZATION_IDS + 1), + }).success + ).toBe(false); + }); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index e5ebb270cf..e45cf584f7 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -40,6 +40,7 @@ export { CostSourceSchema, DimensionSchema, GranularitySchema, + MAX_SCOPE_ORGANIZATION_IDS, MetricSchema, SummaryOutputSchema, TableInputSchema, diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts index 98fe8c50e9..8b8fa85103 100644 --- a/apps/web/src/routers/usage-analytics-schemas.ts +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -1,5 +1,12 @@ import * as z from 'zod'; +/** + * Upper bound on the orgs in an "All Organizations" aggregate (a parent plus + * its children). Caps authorization fan-out and the generated SQL `IN` clause + * for this caller-controlled input. + */ +export const MAX_SCOPE_ORGANIZATION_IDS = 100; + export const GranularitySchema = z.enum(['hour', 'day', 'week', 'month']); export type Granularity = z.infer; @@ -37,7 +44,7 @@ const FiltersShape = { * `organizationId` and is always treated as an org-wide view. The caller must * have owner/billing_manager access to every listed org. */ - organizationIds: z.array(z.uuid()).optional(), + organizationIds: z.array(z.uuid()).max(MAX_SCOPE_ORGANIZATION_IDS).optional(), personalScope: z.enum(['personal-only', 'include-orgs']).default('personal-only'), viewAs: z.enum(['self', 'org-wide']).default('self'), features: z.array(z.string()).optional(), From c2945022370a88820c6b3787fd27e03f5520df2b Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 13:54:11 +0200 Subject: [PATCH 4/7] fix(web): batch org scope auth, raise cap, migrate legacy URLs - Replace per-org authorization fan-out with batched helpers (getOrganizationsAccessRoles / ensureOrganizationsAccess) so the multi-org scope and resolveOrgUsers use a fixed number of queries. - Raise MAX_SCOPE_ORGANIZATION_IDS to 1000 so large parent hierarchies can use All Organizations without hitting the validation cap. - Migrate legacy `?viewAs=org-wide` deep links to the new `scope` model so they keep opening an org-wide view instead of collapsing to My Usage. --- .../UsageAnalyticsDashboard.tsx | 17 +++- .../usage-analytics/useUsageDashboardState.ts | 2 + apps/web/src/routers/organizations/utils.ts | 85 +++++++++++++++++++ .../web/src/routers/usage-analytics-router.ts | 40 ++++++--- .../src/routers/usage-analytics-schemas.ts | 8 +- 5 files changed, 134 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index f507d595c6..09036db19a 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -1,5 +1,6 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { skipToken, useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; import { Button } from '@/components/ui/button'; @@ -120,7 +121,21 @@ export function UsageAnalyticsDashboard({ title, }: UsageAnalyticsDashboardProps) { const trpc = useTRPC(); - const { state, setState } = useUsageDashboardState(); + // Migrate legacy `?viewAs=org-wide` links (which meant page-org-wide) to the + // new `scope` model so existing bookmarks keep opening an org-wide view + // instead of silently collapsing to "My Usage". Only applied when no explicit + // `scope` is present. + const searchParams = useSearchParams(); + const legacyOrgWideScope = + context === 'organization' && + organizationId && + searchParams.get('scope') == null && + searchParams.get('viewAs') === 'org-wide' + ? organizationId + : undefined; + const { state, setState } = useUsageDashboardState( + legacyOrgWideScope ? { orgScope: legacyOrgWideScope } : undefined + ); const { period, granularity, diff --git a/apps/web/src/components/usage-analytics/useUsageDashboardState.ts b/apps/web/src/components/usage-analytics/useUsageDashboardState.ts index 53c245ff46..d9200c0bd4 100644 --- a/apps/web/src/components/usage-analytics/useUsageDashboardState.ts +++ b/apps/web/src/components/usage-analytics/useUsageDashboardState.ts @@ -342,6 +342,8 @@ export function useUsageDashboardState(defaultState?: Partial): } else { params.delete('scope'); } + // Drop the legacy org-wide param once migrated to `scope` (see dashboard). + params.delete('viewAs'); if (state.usageView === 'overview') { params.delete('view'); diff --git a/apps/web/src/routers/organizations/utils.ts b/apps/web/src/routers/organizations/utils.ts index babc57d1d9..63cf052875 100644 --- a/apps/web/src/routers/organizations/utils.ts +++ b/apps/web/src/routers/organizations/utils.ts @@ -77,6 +77,91 @@ export async function ensureOrganizationAccess( return role; } +/** + * Resolves the caller's effective role for each of `organizationIds` in a fixed + * number of queries (vs one per org). Mirrors {@link ensureOrganizationAccess}: + * admins get 'owner' everywhere, otherwise direct memberships are unioned with + * roles inherited from a parent org (owner/billing_manager). Orgs the caller has + * no access to are simply absent from the returned map. + */ +export async function getOrganizationsAccessRoles( + ctx: TRPCContext, + organizationIds: Organization['id'][] +): Promise> { + const uniqueIds = Array.from(new Set(organizationIds)); + const result = new Map(); + if (uniqueIds.length === 0) { + return result; + } + if (ctx.user.is_admin) { + for (const id of uniqueIds) result.set(id, 'owner'); + return result; + } + + const directRows = await db + .select({ + organizationId: organization_memberships.organization_id, + role: organization_memberships.role, + }) + .from(organization_memberships) + .where( + and( + eq(organization_memberships.kilo_user_id, ctx.user.id), + inArray(organization_memberships.organization_id, uniqueIds) + ) + ); + + const inheritedRows = await db + .select({ + organizationId: organizations.id, + role: organization_memberships.role, + }) + .from(organizations) + .innerJoin( + organization_memberships, + and( + eq(organization_memberships.organization_id, organizations.parent_organization_id), + eq(organization_memberships.kilo_user_id, ctx.user.id), + inArray(organization_memberships.role, parentOrganizationAccessRoles) + ) + ) + .where(inArray(organizations.id, uniqueIds)); + + const rolesByOrg = new Map(); + for (const row of [...directRows, ...inheritedRows]) { + const list = rolesByOrg.get(row.organizationId) ?? []; + list.push(row.role); + rolesByOrg.set(row.organizationId, list); + } + for (const [organizationId, roles] of rolesByOrg) { + const best = rolePriority.find(role => roles.includes(role)); + if (best) result.set(organizationId, best); + } + return result; +} + +/** + * Batched variant of {@link ensureOrganizationAccess} for many orgs at once. + * Throws unless the caller has an allowed role on *every* org in the list. + */ +export async function ensureOrganizationsAccess( + ctx: TRPCContext, + organizationIds: Organization['id'][], + roles?: OrganizationRole[] +): Promise { + const accessByOrg = await getOrganizationsAccessRoles(ctx, organizationIds); + const allowedRoles = roles && roles.length > 0 ? roles : rolePriority; + for (const organizationId of new Set(organizationIds)) { + const role = accessByOrg.get(organizationId); + if (!role || !allowedRoles.includes(role)) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You do not have access to this organization', + }); + } + } +} + export async function ensureOrganizationAccessAndFetchOrg( ctx: TRPCContext, organizationId: Organization['id'], diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index e45cf584f7..6022d086f6 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -16,10 +16,15 @@ import { user_auth_provider, } from '@kilocode/db/schema'; import type { AuthProviderId } from '@kilocode/db/schema-types'; -import { ensureOrganizationAccess } from '@/routers/organizations/utils'; +import { + ensureOrganizationAccess, + ensureOrganizationsAccess, + getOrganizationsAccessRoles, +} from '@/routers/organizations/utils'; import { BreakdownInputSchema, BreakdownOutputSchema, + MAX_SCOPE_ORGANIZATION_IDS, SummaryOutputSchema, TableInputSchema, TableOutputSchema, @@ -213,12 +218,10 @@ async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilter // must be owner/billing_manager of every org in the list. A parent owner has // inherited owner/billing access to children, so this passes for the parent // plus all of its children while rejecting any org they cannot administer. + // 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 Promise.all( - filters.organizationIds.map(orgId => - ensureOrganizationAccess(ctx, orgId, ['owner', 'billing_manager']) - ) - ); + await ensureOrganizationsAccess(ctx, filters.organizationIds, ['owner', 'billing_manager']); return; } @@ -572,10 +575,9 @@ function toSafeNumber(value: unknown): number { // --------------------------------------------------------------------------- const MAX_USER_LABEL_LOOKUP_IDS = 1_000; -const MAX_USER_LABEL_LOOKUP_ORGS = 100; const UserListInputSchema = z.object({ - organizationIds: z.array(z.uuid()).min(1).max(MAX_USER_LABEL_LOOKUP_ORGS), + organizationIds: z.array(z.uuid()).min(1).max(MAX_SCOPE_ORGANIZATION_IDS), userIds: z.array(z.string()).max(MAX_USER_LABEL_LOOKUP_IDS), }); @@ -1080,13 +1082,23 @@ export const usageAnalyticsRouter = createTRPCRouter({ .input(UserListInputSchema) .output(UserListOutputSchema) .query(async ({ input, ctx }) => { - const roles = await Promise.all( - input.organizationIds.map(orgId => ensureOrganizationAccess(ctx, orgId)) - ); + const accessByOrg = await getOrganizationsAccessRoles(ctx, input.organizationIds); - const canSeeAllMembers = roles.every( - role => role === 'owner' || role === 'billing_manager' - ); + // Require access to every requested org (mirrors the single-org guard). + const hasAccessToAll = input.organizationIds.every(orgId => accessByOrg.has(orgId)); + if (!hasAccessToAll) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You do not have access to this organization', + }); + } + + // Only owner/billing_manager of *every* requested org may resolve other + // members; anyone else can resolve only their own id. + const canSeeAllMembers = input.organizationIds.every(orgId => { + const role = accessByOrg.get(orgId); + return role === 'owner' || role === 'billing_manager'; + }); const allowedIds = canSeeAllMembers ? input.userIds : input.userIds.filter(id => id === ctx.user.id); diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts index 8b8fa85103..9fa24c5385 100644 --- a/apps/web/src/routers/usage-analytics-schemas.ts +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -2,10 +2,12 @@ import * as z from 'zod'; /** * Upper bound on the orgs in an "All Organizations" aggregate (a parent plus - * its children). Caps authorization fan-out and the generated SQL `IN` clause - * for this caller-controlled input. + * its children). Bounds this caller-controlled input so it cannot generate an + * unbounded SQL `IN` clause, while staying well above any realistic two-level + * org hierarchy so legitimate parents are never rejected. (Authorization is + * batched into a fixed number of queries, so the cap need not be tight.) */ -export const MAX_SCOPE_ORGANIZATION_IDS = 100; +export const MAX_SCOPE_ORGANIZATION_IDS = 1_000; export const GranularitySchema = z.enum(['hour', 'day', 'week', 'month']); export type Granularity = z.infer; From e63c07efd75137ffb75b19fd7e72772b80eadb3b Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 14:04:42 +0200 Subject: [PATCH 5/7] fix(web): scope org-level usage panels to the selected org AIAdoptionScoreCard and ActiveKiloclawsTable now follow the selected single org instead of always the page org, and are hidden in the All Organizations aggregate (which they cannot represent), so the dashboard no longer mixes scopes when an admin switches to a child org. --- .../usage-analytics/UsageAnalyticsDashboard.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index 09036db19a..e6d3c68af1 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -687,8 +687,11 @@ export function UsageAnalyticsDashboard({ <> - {isOrgContext && organizationId && ( - + {/* Org-level panels follow the selected single org so they + don't mix scopes; hidden in the All Organizations aggregate + (effectiveOrgId is null) which they cannot represent. */} + {isOrgContext && effectiveOrgId && ( + )} {isOrgContext && - organizationId && + effectiveOrgId && (callerRole === 'owner' || callerRole === 'billing_manager') && ( - + )} )} From 72ea8e129571161dab36b6eb24c30abf42a552d8 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 14:18:29 +0200 Subject: [PATCH 6/7] fix(web): clamp org scope when scope-list fetch fails Derive scopeListPending from the query's isLoading state instead of the absence of data, so a failed getScopeOrganizations request falls back to clamping the scope (to My Usage) rather than honoring a stale/unknown URL scope indefinitely. --- .../components/usage-analytics/UsageAnalyticsDashboard.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index e6d3c68af1..0f6dd71e98 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -167,11 +167,12 @@ export function UsageAnalyticsDashboard({ // Parent/child hierarchy for the org-context Scope selector. Only fetched for // owners/billing_managers; members never see the expanded scope list. - const { data: scopeOrgs } = useQuery( + const scopeOrgsQuery = useQuery( trpc.usageAnalytics.getScopeOrganizations.queryOptions( isOrgAdmin && organizationId ? { organizationId } : skipToken ) ); + const scopeOrgs = scopeOrgsQuery.data; const childOrganizations = useMemo(() => scopeOrgs?.children ?? [], [scopeOrgs]); const isParentOrg = childOrganizations.length > 0; @@ -210,7 +211,9 @@ export function UsageAnalyticsDashboard({ // rather than clamp: otherwise a deep link like `?scope=&group=user` // would momentarily resolve to 'self', and the cleanup effect below would wipe // (and persist) the deep-linked grouping/user filters before validation runs. - const scopeListPending = isOrgAdmin && !!organizationId && !scopeOrgs; + // Keyed off `isLoading` (not `!data`) so a failed scope-list fetch falls back + // to clamping instead of honoring a stale/unknown scope indefinitely. + const scopeListPending = isOrgAdmin && !!organizationId && scopeOrgsQuery.isLoading; const resolvedOrgScope = !isOrgAdmin ? ORG_SCOPE_SELF : scopeListPending || validOrgScopeValues.has(orgScope) From 920a4db40745aec264d2a0335cf0bb50d2b717b7 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 14:22:24 +0200 Subject: [PATCH 7/7] style(web): apply oxfmt formatting to usage analytics --- .../components/usage-analytics/UsageAnalyticsDashboard.tsx | 3 +-- apps/web/src/routers/usage-analytics-router.test.ts | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index 0f6dd71e98..2462d5aa7d 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -151,8 +151,7 @@ export function UsageAnalyticsDashboard({ const isOrgContext = context === 'organization'; // Owners/billing_managers are the only roles that may view org-wide usage and // (via inheritance) child-org usage, so only they get the expanded scope list. - const isOrgAdmin = - isOrgContext && (callerRole === 'owner' || callerRole === 'billing_manager'); + const isOrgAdmin = isOrgContext && (callerRole === 'owner' || callerRole === 'billing_manager'); const hasEnterpriseUsageViews = context === 'organization' && organizationPlan === 'enterprise'; const showDetailedUsage = !hasEnterpriseUsageViews || usageView === 'ai-usage'; diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index 92a101b631..9c8d8232eb 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -96,17 +96,14 @@ describe('usage analytics scope conditions', () => { it('falls back to personal scope with no org', () => { const { sql, bindings } = scopeSql({}); expect(sql).toContain('kilo_user_id = ?'); - expect(sql).toContain("organization_id = ?"); + expect(sql).toContain('organization_id = ?'); // personal-only pins kilo_user_id to caller and org to the empty-string sentinel expect(bindings).toEqual([CTX_USER, '']); }); it('caps organizationIds at the boundary to bound auth fan-out', () => { const makeIds = (n: number) => - Array.from( - { length: n }, - (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}` - ); + Array.from({ length: n }, (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`); expect( UsageAnalyticsFiltersSchema.safeParse({ ...baseFilters,