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..2462d5aa7d 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'next/navigation'; +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 +43,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, @@ -116,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, @@ -125,10 +144,14 @@ 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 +164,17 @@ 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 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; + const dateRange = useMemo(() => periodToDateRange(period), [period]); const granularityOptions = useMemo(() => granularityOptionsForPeriod(period), [period]); @@ -151,40 +185,81 @@ 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. + // + // 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. + // 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) + ? 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]); - - const canViewAllOrgUsers = - !!effectiveOrgId && - (roleForEffectiveOrg === 'owner' || roleForEffectiveOrg === 'billing_manager'); + if (isOrgContext) return callerRole; + if (!personalEffectiveOrgId) return undefined; + return organizations?.find(o => o.organizationId === personalEffectiveOrgId)?.role; + }, [isOrgContext, callerRole, personalEffectiveOrgId, organizations]); - // 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 +269,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 +314,7 @@ export function UsageAnalyticsDashboard({ }), [ effectiveOrgId, + effectiveOrganizationIds, dateRange, granularity, costSource, @@ -306,7 +381,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 +390,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 +406,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 +568,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} @@ -611,8 +689,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') && ( - + )} )} 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..d9200c0bd4 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,11 +337,13 @@ 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'); } + // 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.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index efe2113446..9c8d8232eb 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -2,7 +2,10 @@ jest.mock('@/lib/redis', () => ({ redisClient: {} })); import { CostSourceSchema, + MAX_SCOPE_ORGANIZATION_IDS, UsageAnalyticsFiltersSchema, + WhereBuilder, + buildScopeConditions, costColumnFor, costSumExprSql, } from './usage-analytics-router'; @@ -13,6 +16,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 +49,72 @@ 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, '']); + }); + + 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 35858b2b13..6022d086f6 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, isNull, or } from 'drizzle-orm'; import { baseProcedure, createTRPCRouter, type TRPCContext } from '@/lib/trpc/init'; import { readDb } from '@/lib/drizzle'; import { getEnvVariable } from '@/lib/dotenvx'; @@ -9,12 +9,22 @@ 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 { + ensureOrganizationAccess, + ensureOrganizationsAccess, + getOrganizationsAccessRoles, +} from '@/routers/organizations/utils'; import { BreakdownInputSchema, BreakdownOutputSchema, + MAX_SCOPE_ORGANIZATION_IDS, SummaryOutputSchema, TableInputSchema, TableOutputSchema, @@ -35,6 +45,7 @@ export { CostSourceSchema, DimensionSchema, GranularitySchema, + MAX_SCOPE_ORGANIZATION_IDS, MetricSchema, SummaryOutputSchema, TableInputSchema, @@ -137,7 +148,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 +206,25 @@ 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. + // 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']); + return; + } + if (filters.organizationId) { const requiredRoles = filters.viewAs === 'org-wide' ? (['owner', 'billing_manager'] as const) : undefined; @@ -256,11 +284,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') { @@ -537,7 +577,7 @@ function toSafeNumber(value: unknown): number { const MAX_USER_LABEL_LOOKUP_IDS = 1_000; const UserListInputSchema = z.object({ - organizationId: z.uuid(), + organizationIds: z.array(z.uuid()).min(1).max(MAX_SCOPE_ORGANIZATION_IDS), userIds: z.array(z.string()).max(MAX_USER_LABEL_LOOKUP_IDS), }); @@ -551,6 +591,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 +714,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 +723,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 +821,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 +830,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 +881,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 +890,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 +973,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 +982,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 +1021,84 @@ 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(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( + and( + eq(organizations.parent_organization_id, input.organizationId), + isNull(organizations.deleted_at) + ) + ) + .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 accessByOrg = await getOrganizationsAccessRoles(ctx, input.organizationIds); + + // 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', + }); + } - const canSeeAllMembers = role === 'owner' || role === 'billing_manager'; + // 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); @@ -995,7 +1116,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 +1168,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..9fa24c5385 100644 --- a/apps/web/src/routers/usage-analytics-schemas.ts +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -1,5 +1,14 @@ import * as z from 'zod'; +/** + * Upper bound on the orgs in an "All Organizations" aggregate (a parent plus + * 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 = 1_000; + export const GranularitySchema = z.enum(['hour', 'day', 'week', 'month']); export type Granularity = z.infer; @@ -31,6 +40,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()).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(),