From 25f003c459b5090fdc75bb162507271e7443a72f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 2 Jun 2025 21:17:52 -0400 Subject: [PATCH] Show individual usage stats to members (#70) * Show individual usage stats to members * Tighter security * PR feedback --- src/actions/analytics/events.ts | 202 +++++++++++++++++++----- src/app/(authenticated)/layout.tsx | 28 ++-- src/app/(authenticated)/usage/Tasks.tsx | 14 +- src/app/(authenticated)/usage/Usage.tsx | 55 ++++--- src/app/(authenticated)/usage/page.tsx | 5 +- src/components/layout/NavbarMenu.tsx | 24 ++- src/components/usage/TaskCard.tsx | 16 +- src/components/usage/UsageCard.tsx | 14 +- src/components/usage/UsageChart.tsx | 11 +- 9 files changed, 286 insertions(+), 83 deletions(-) diff --git a/src/actions/analytics/events.ts b/src/actions/analytics/events.ts index c95d54b1b8..3c1c135fec 100644 --- a/src/actions/analytics/events.ts +++ b/src/actions/analytics/events.ts @@ -1,6 +1,7 @@ 'use server'; import { z } from 'zod'; +import { auth } from '@clerk/nextjs/server'; import { TelemetryEventName, @@ -14,6 +15,56 @@ import { type User, getUsersById } from '@/db/server'; type Table = 'events' | 'messages'; +/** + * Validates authentication and authorization for analytics functions + */ +async function validateAnalyticsAccess({ + requestedOrgId, + requestedUserId, + requireAdmin = false, +}: { + requestedOrgId?: string | null; + requestedUserId?: string | null; + requireAdmin?: boolean; +}): Promise<{ + authOrgId: string; + authUserId: string; + orgRole: string; + effectiveUserId: string | null; +}> { + const { orgId: authOrgId, orgRole, userId: authUserId } = await auth(); + + // Ensure user is authenticated and belongs to the organization + if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) { + throw new Error('Unauthorized: Invalid organization access'); + } + + // Check if admin access is required + if (requireAdmin && orgRole !== 'org:admin') { + throw new Error('Unauthorized: Administrator access required'); + } + + // If user is not an admin and trying to access data other than their own + if ( + orgRole !== 'org:admin' && + requestedUserId && + requestedUserId !== authUserId + ) { + throw new Error('Unauthorized: Members can only access their own data'); + } + + // For non-admin users, force userId filter to their own ID + const effectiveUserId = + orgRole !== 'org:admin' ? authUserId : requestedUserId || null; + + return { + authOrgId, + authUserId, + orgRole: orgRole || 'unknown', + effectiveUserId, + }; +} + /** * captureEvent */ @@ -78,14 +129,30 @@ type UsageRecord = Partial>; export const getUsage = async ({ orgId, timePeriod = 90, + userId, }: { orgId?: string | null; timePeriod?: AnyTimePeriod; + userId?: string | null; }): Promise => { + const { effectiveUserId } = await validateAnalyticsAccess({ + requestedOrgId: orgId, + requestedUserId: userId, + }); + if (!orgId) { return {}; } + const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : ''; + const queryParams: Record = { + orgId: orgId!, + timePeriod, + }; + if (effectiveUserId) { + queryParams.userId = effectiveUserId; + } + const results = await analytics.query({ query: ` SELECT @@ -98,10 +165,11 @@ export const getUsage = async ({ WHERE orgId = {orgId: String} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) + ${userFilter} GROUP BY 1 `, format: 'JSONEachRow', - query_params: { orgId, timePeriod }, + query_params: queryParams, }); return z @@ -132,14 +200,36 @@ export type DeveloperUsage = z.infer & { export const getDeveloperUsage = async ({ orgId, timePeriod = 90, + userId, }: { orgId?: string | null; timePeriod?: AnyTimePeriod; + userId?: string | null; }): Promise => { + await validateAnalyticsAccess({ + requestedOrgId: orgId, + requestedUserId: userId, + requireAdmin: true, + }); + if (!orgId) { return []; } + const userFilter = userId ? 'AND userId = {userId: String}' : ''; + const queryParams: Record = { + orgId: orgId!, + timePeriod, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }; + if (userId) { + queryParams.userId = userId; + } + const results = await analytics.query({ query: ` SELECT @@ -152,18 +242,11 @@ export const getDeveloperUsage = async ({ WHERE orgId = {orgId: String} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) + ${userFilter} GROUP BY 1 `, format: 'JSONEachRow', - query_params: { - orgId, - timePeriod, - types: [ - TelemetryEventName.TASK_CREATED, - TelemetryEventName.TASK_COMPLETED, - TelemetryEventName.LLM_COMPLETION, - ], - }, + query_params: queryParams, }); const developerUsages = z @@ -194,14 +277,36 @@ export type ModelUsage = z.infer; export const getModelUsage = async ({ orgId, timePeriod = 90, + userId, }: { orgId?: string | null; timePeriod?: AnyTimePeriod; + userId?: string | null; }): Promise => { + await validateAnalyticsAccess({ + requestedOrgId: orgId, + requestedUserId: userId, + requireAdmin: true, + }); + if (!orgId) { return []; } + const userFilter = userId ? 'AND userId = {userId: String}' : ''; + const queryParams: Record = { + orgId: orgId!, + timePeriod, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }; + if (userId) { + queryParams.userId = userId; + } + const results = await analytics.query({ query: ` SELECT @@ -215,18 +320,11 @@ export const getModelUsage = async ({ orgId = {orgId: String} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) + ${userFilter} GROUP BY 1, 2 `, format: 'JSONEachRow', - query_params: { - orgId, - timePeriod, - types: [ - TelemetryEventName.TASK_CREATED, - TelemetryEventName.TASK_COMPLETED, - TelemetryEventName.LLM_COMPLETION, - ], - }, + query_params: queryParams, }); return z.array(modelUsageSchema).parse(await results.json()); @@ -246,13 +344,36 @@ export type TaskWithUser = TaskWithTitle & { user: User }; export const getTasks = async ({ orgId, + userId, }: { orgId?: string | null; + userId?: string | null; }): Promise => { + const { effectiveUserId } = await validateAnalyticsAccess({ + requestedOrgId: orgId, + requestedUserId: userId, + }); + if (!orgId) { return []; } + const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : ''; + const messageUserFilter = effectiveUserId + ? 'AND userId = {userId: String}' + : ''; + const queryParams: Record = { + orgId: orgId!, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }; + if (effectiveUserId) { + queryParams.userId = effectiveUserId; + } + const results = await analytics.query({ query: ` WITH first_messages AS ( @@ -262,6 +383,7 @@ export const getTasks = async ({ argMin(mode, ts) as mode FROM messages WHERE orgId = {orgId: String} + ${messageUserFilter} GROUP BY taskId ) SELECT @@ -280,18 +402,12 @@ export const getTasks = async ({ WHERE e.orgId = {orgId: String} AND e.type IN ({types: Array(String)}) + ${userFilter} GROUP BY 1, 2 ORDER BY timestamp DESC `, format: 'JSONEachRow', - query_params: { - orgId, - types: [ - TelemetryEventName.TASK_CREATED, - TelemetryEventName.TASK_COMPLETED, - TelemetryEventName.LLM_COMPLETION, - ], - }, + query_params: queryParams, }); const tasks = z.array(taskWithTitleSchema).parse(await results.json()); @@ -322,14 +438,35 @@ export type HourlyUsageByUser = z.infer & { export const getHourlyUsageByUser = async ({ orgId, timePeriod = 90, + userId, }: { orgId?: string | null; timePeriod?: AnyTimePeriod; + userId?: string | null; }): Promise => { + const { effectiveUserId } = await validateAnalyticsAccess({ + requestedOrgId: orgId, + requestedUserId: userId, + }); + if (!orgId) { return []; } + const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : ''; + const queryParams: Record = { + orgId: orgId!, + timePeriod, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }; + if (effectiveUserId) { + queryParams.userId = effectiveUserId; + } + const results = await analytics.query({ query: ` SELECT @@ -343,19 +480,12 @@ export const getHourlyUsageByUser = async ({ orgId = {orgId: String} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) + ${userFilter} GROUP BY 1, 2 ORDER BY hour_utc DESC, userId `, format: 'JSONEachRow', - query_params: { - orgId, - timePeriod, - types: [ - TelemetryEventName.TASK_CREATED, - TelemetryEventName.TASK_COMPLETED, - TelemetryEventName.LLM_COMPLETION, - ], - }, + query_params: queryParams, }); const hourlyUsages = z diff --git a/src/app/(authenticated)/layout.tsx b/src/app/(authenticated)/layout.tsx index 420a039f66..abaa4b5867 100644 --- a/src/app/(authenticated)/layout.tsx +++ b/src/app/(authenticated)/layout.tsx @@ -1,33 +1,39 @@ import { redirect } from 'next/navigation'; import { auth } from '@clerk/nextjs/server'; -import { - NavbarHeader, - NavbarMenu, - Section, - Connected, -} from '@/components/layout'; +import { NavbarHeader, NavbarMenu, Section } from '@/components/layout'; +import { Usage } from './usage/Usage'; export default async function AuthenticatedLayout({ children, }: { children: React.ReactNode; }) { - const { orgId, orgRole } = await auth(); + const { orgId, orgRole, userId } = await auth(); if (!orgId) { redirect('/select-org'); } - // For now, a non-admin just get a "You're connected" message. - if (orgRole !== 'org:admin') { - return ; + // Members get access to usage page only, filtered to their own data + if (orgRole === 'org:member' || (orgRole && orgRole !== 'org:admin')) { + return ( + <> + + +
+
+ +
+
+ + ); } return ( <> - +
{children}
diff --git a/src/app/(authenticated)/usage/Tasks.tsx b/src/app/(authenticated)/usage/Tasks.tsx index 76a7256455..e7f6be13a1 100644 --- a/src/app/(authenticated)/usage/Tasks.tsx +++ b/src/app/(authenticated)/usage/Tasks.tsx @@ -13,16 +13,24 @@ export const Tasks = ({ filter, onFilter, onTaskSelected, + userRole = 'admin', + currentUserId, }: { filter: Filter | null; onFilter: (filter: Filter) => void; onTaskSelected: (task: TaskWithUser) => void; + userRole?: 'admin' | 'member'; + currentUserId?: string | null; }) => { const { orgId } = useAuth(); const { data = [], isPending } = useQuery({ - queryKey: ['getTasks', orgId], - queryFn: () => getTasks({ orgId }), + queryKey: ['getTasks', orgId, userRole === 'member' ? currentUserId : null], + queryFn: () => + getTasks({ + orgId, + userId: userRole === 'member' ? currentUserId : undefined, + }), enabled: !!orgId, }); @@ -64,7 +72,7 @@ export const Tasks = ({ ))} diff --git a/src/app/(authenticated)/usage/Usage.tsx b/src/app/(authenticated)/usage/Usage.tsx index 346c84da6a..be0848a588 100644 --- a/src/app/(authenticated)/usage/Usage.tsx +++ b/src/app/(authenticated)/usage/Usage.tsx @@ -14,7 +14,12 @@ import { Models } from './Models'; import { Tasks } from './Tasks'; import { TaskDrawer } from './TaskDrawer'; -export const Usage = () => { +type UsageProps = { + userRole?: 'admin' | 'member'; + currentUserId?: string | null; +}; + +export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => { const t = useTranslations('Analytics'); const [viewMode, setViewMode] = useState('tasks'); const [filter, setFilter] = useState(null); @@ -25,24 +30,36 @@ export const Usage = () => { setViewMode('tasks'); }, []); + // For members, automatically set filter to their user ID and hide other tabs + const isMember = userRole === 'member'; + const availableViewModes = isMember ? (['tasks'] as const) : viewModes; + + // Auto-apply user filter for members + const effectiveFilter = + isMember && currentUserId + ? { type: 'userId' as const, value: currentUserId, label: 'Your Tasks' } + : filter; + return ( <>
- -
- {viewModes.map((mode) => ( - - ))} -
- {filter && ( + + {!isMember && ( +
+ {availableViewModes.map((mode) => ( + + ))} +
+ )} + {filter && !isMember && (
@@ -60,9 +77,11 @@ export const Usage = () => { )} {viewMode === 'tasks' ? ( {} : onFilter} onTaskSelected={(task: TaskWithUser) => setTask(task)} + userRole={userRole} + currentUserId={currentUserId} /> ) : viewMode === 'developers' ? ( diff --git a/src/app/(authenticated)/usage/page.tsx b/src/app/(authenticated)/usage/page.tsx index 7e8f502269..0c4c472467 100644 --- a/src/app/(authenticated)/usage/page.tsx +++ b/src/app/(authenticated)/usage/page.tsx @@ -1,5 +1,8 @@ +import { auth } from '@clerk/nextjs/server'; import { Usage } from './Usage'; export default async function Page() { - return ; + const { userId } = await auth(); + + return ; } diff --git a/src/components/layout/NavbarMenu.tsx b/src/components/layout/NavbarMenu.tsx index 1f4de78471..afabd3cbdf 100644 --- a/src/components/layout/NavbarMenu.tsx +++ b/src/components/layout/NavbarMenu.tsx @@ -7,7 +7,12 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/ecosystem'; import { Section } from './Section'; -type NavbarMenuProps = Omit, 'children'>; +type NavbarMenuProps = Omit< + React.HTMLAttributes, + 'children' +> & { + userRole?: 'admin' | 'member'; +}; const tabValues = [ '/usage', @@ -23,7 +28,10 @@ type TabValue = (typeof tabValues)[number]; const isTabValue = (value: string): value is TabValue => tabValues.includes(value as TabValue); -export const NavbarMenu = (props: NavbarMenuProps) => { +export const NavbarMenu = ({ + userRole = 'admin', + ...props +}: NavbarMenuProps) => { const router = useRouter(); const pathname = usePathname(); const [tabValue, setTabValue] = useState(undefined); @@ -50,10 +58,14 @@ export const NavbarMenu = (props: NavbarMenuProps) => { > Usage - Audit Logs - Providers - Telemetry - Organization + {userRole === 'admin' && ( + <> + Audit Logs + Providers + Telemetry + Organization + + )} diff --git a/src/components/usage/TaskCard.tsx b/src/components/usage/TaskCard.tsx index 746085ff60..b5ed1d3ca1 100644 --- a/src/components/usage/TaskCard.tsx +++ b/src/components/usage/TaskCard.tsx @@ -20,7 +20,7 @@ import { Status } from '@/app/(authenticated)/usage/Status'; type TaskCardProps = { task: TaskWithUser; - onFilter: (filter: Filter) => void; + onFilter?: (filter: Filter) => void; onTaskSelected: (task: TaskWithUser) => void; }; @@ -82,13 +82,17 @@ export const TaskCard = ({ task, onFilter, onTaskSelected }: TaskCardProps) => { size="sm" onClick={(e) => { e.stopPropagation(); - onFilter({ - type: 'userId', - value: task.userId, - label: task.user.name, - }); + // Only allow filtering if onFilter is provided (disabled for members) + if (onFilter) { + onFilter({ + type: 'userId', + value: task.userId, + label: task.user.name, + }); + } }} className="px-0 h-auto text-xs font-normal text-muted-foreground hover:text-foreground" + disabled={!onFilter} > {task.user.name} diff --git a/src/components/usage/UsageCard.tsx b/src/components/usage/UsageCard.tsx index ee3767cbff..c61b63c69d 100644 --- a/src/components/usage/UsageCard.tsx +++ b/src/components/usage/UsageCard.tsx @@ -27,7 +27,15 @@ import { UsageChart } from './UsageChart'; type MetricType = 'tasks' | 'tokens' | 'cost'; -export const UsageCard = () => { +type UsageCardProps = { + userRole?: 'admin' | 'member'; + currentUserId?: string | null; +}; + +export const UsageCard = ({ + userRole = 'admin', + currentUserId, +}: UsageCardProps) => { const t = useTranslations('DashboardIndex'); const { orgId } = useAuth(); const [selectedPeriod, setSelectedPeriod] = useState( @@ -43,6 +51,7 @@ export const UsageCard = () => { orgId, selectedPeriod.value, selectedPeriod.granularity, + userRole === 'member' ? currentUserId : null, ], queryFn: () => getUsage({ @@ -51,6 +60,7 @@ export const UsageCard = () => { selectedPeriod.granularity === 'daily' ? (selectedPeriod.value as 7 | 30 | 90) : (selectedPeriod.value as 1), // Use 1 day for 24h view + userId: userRole === 'member' ? currentUserId : undefined, }), enabled: !!orgId, }); @@ -169,6 +179,8 @@ export const UsageCard = () => { )}
diff --git a/src/components/usage/UsageChart.tsx b/src/components/usage/UsageChart.tsx index 8de9a18f42..a22fcce816 100644 --- a/src/components/usage/UsageChart.tsx +++ b/src/components/usage/UsageChart.tsx @@ -283,6 +283,8 @@ const processDailyDataForChart = ( interface UsageChartProps { timePeriodConfig: TimePeriodConfig; selectedMetric?: MetricType; + userRole?: 'admin' | 'member'; + currentUserId?: string | null; } // Custom tick components for theme-aware labels @@ -432,6 +434,8 @@ const CustomTooltip = ({ export const UsageChart = ({ timePeriodConfig, selectedMetric = 'tasks', + userRole = 'admin', + currentUserId, }: UsageChartProps) => { const { orgId } = useAuth(); const [isClient, setIsClient] = useState(false); @@ -447,9 +451,14 @@ export const UsageChart = ({ orgId, timePeriodConfig.value, timePeriodConfig.granularity, + userRole === 'member' ? currentUserId : null, ], queryFn: () => - getHourlyUsageByUser({ orgId, timePeriod: timePeriodConfig.value }), + getHourlyUsageByUser({ + orgId, + timePeriod: timePeriodConfig.value, + userId: userRole === 'member' ? currentUserId : undefined, + }), enabled: !!orgId, });