diff --git a/apps/studio/components/interfaces/Auth/Users/UserJourney.tsx b/apps/studio/components/interfaces/Auth/Users/UserJourney.tsx index 166869fa75b..08757211e1b 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserJourney.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserJourney.tsx @@ -1,89 +1,49 @@ /** * UserJourney — PROTOTYPE * - * This is a demo-quality prototype of the "User Journey" tab. It renders a - * chronological timeline reconstructed from Auth and PostgREST logs using - * HARDCODED MOCK DATA — there are no real log queries wired up yet. + * Renders a chronological timeline of everything one user did, reconstructed + * from Auth and PostgREST logs: signed up, authenticated, API reads/writes, + * and where a request was denied. + * + * This wires up REAL log queries (auth_logs + edge_logs filtered by the user) + * via useLogsQuery. Where the logs can't tell the full story — most notably the + * exact RLS policy that denied a write — it degrades gracefully (see the + * data-availability note in UserJourney.utils.ts). A "Sample data" toggle shows + * a hardcoded reference scenario so the tab stays demoable on projects with no + * matching logs. * * See PRFAQ: https://www.notion.so/supabase/User-Journey-PRFAQ (placeholder link) */ -import { useParams } from 'common' -import { AlertTriangle, Database, ExternalLink, LogIn, RefreshCw, UserPlus } from 'lucide-react' +import { useFlag, useParams } from 'common' +import { + AlertTriangle, + Database, + ExternalLink, + FlaskConical, + LogIn, + RefreshCw, + UserPlus, +} from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Button, cn, Separator } from 'ui' import { Admonition } from 'ui-patterns/admonition' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { UserHeader } from './UserHeader' +import { + buildAuthLogsSql, + buildEdgeLogsSql, + buildJourney, + isValidUserId, + SAMPLE_JOURNEY_EVENTS, + type JourneyEvent, +} from './UserJourney.utils' import { PANEL_PADDING } from './Users.constants' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { User } from '@/data/auth/users-infinite-query' - -type JourneyEventStatus = 'success' | 'neutral' | 'error' -type JourneyEventSource = 'auth' | 'postgrest' - -interface JourneyEvent { - id: string - timestamp: string // ISO, ms precision - source: JourneyEventSource - status: JourneyEventStatus - title: string - description: string - request?: { method: string; path: string; statusCode: number } - error?: { message: string; policy?: string; table?: string } -} - -const MOCK_JOURNEY_EVENTS: JourneyEvent[] = [ - { - id: '1', - timestamp: '2026-07-13T09:41:02.118Z', - source: 'auth', - status: 'success', - title: 'Signed up', - description: 'Auth · new user created via email signup', - }, - { - id: '2', - timestamp: '2026-07-13T09:41:02.421Z', - source: 'auth', - status: 'success', - title: 'Authenticated', - description: 'Auth · session issued, JWT minted', - }, - { - id: '3', - timestamp: '2026-07-13T09:41:03.905Z', - source: 'postgrest', - status: 'neutral', - title: 'Read profile', - description: 'PostgREST · fetched the current user profile', - request: { method: 'GET', path: '/rest/v1/profiles', statusCode: 200 }, - }, - { - id: '4', - timestamp: '2026-07-13T09:41:05.332Z', - source: 'postgrest', - status: 'neutral', - title: 'Created order', - description: 'PostgREST · inserted a new order row', - request: { method: 'POST', path: '/rest/v1/orders', statusCode: 201 }, - }, - { - id: '5', - timestamp: '2026-07-13T09:41:06.744Z', - source: 'postgrest', - status: 'error', - title: 'Write blocked', - description: 'PostgREST · insert rejected before reaching the table', - request: { method: 'POST', path: '/rest/v1/payments', statusCode: 403 }, - error: { - message: 'Denied by RLS policy', - policy: 'payments_insert_owner', - table: 'payments', - }, - }, -] +import { useLogsQuery } from '@/hooks/analytics/useLogsQuery' const getEventIcon = (event: JourneyEvent) => { if (event.status === 'error') return AlertTriangle @@ -103,11 +63,58 @@ interface UserJourneyProps { export const UserJourney = ({ user }: UserJourneyProps) => { const { ref } = useParams() - const [errorsOnly, setErrorsOnly] = useState(false) + const useOtel = useFlag('otelLegacyLogs') - const events = errorsOnly - ? MOCK_JOURNEY_EVENTS.filter((event) => event.status === 'error') - : MOCK_JOURNEY_EVENTS + const [errorsOnly, setErrorsOnly] = useState(false) + const [showSample, setShowSample] = useState(false) + + const userId = user.id + const canQuery = isValidUserId(userId) && !showSample + + const authSql = useMemo( + () => (isValidUserId(userId) ? buildAuthLogsSql(userId, useOtel) : ''), + [userId, useOtel] + ) + const edgeSql = useMemo( + () => (isValidUserId(userId) ? buildEdgeLogsSql(userId, useOtel) : ''), + [userId, useOtel] + ) + + const { + logData: authLogs, + isLoading: isLoadingAuth, + error: authError, + runQuery: runAuthQuery, + } = useLogsQuery({ + projectRef: ref, + initialParams: { sql: authSql }, + enabled: canQuery, + options: { useOtel }, + }) + + const { + logData: edgeLogs, + isLoading: isLoadingEdge, + error: edgeError, + runQuery: runEdgeQuery, + } = useLogsQuery({ + projectRef: ref, + initialParams: { sql: edgeSql }, + enabled: canQuery, + options: { useOtel }, + }) + + const liveEvents = useMemo(() => buildJourney(authLogs, edgeLogs), [authLogs, edgeLogs]) + + const isLoading = canQuery && (isLoadingAuth || isLoadingEdge) + const error = authError || edgeError + const allEvents = showSample ? SAMPLE_JOURNEY_EVENTS : liveEvents + const events = errorsOnly ? allEvents.filter((event) => event.status === 'error') : allEvents + + const refresh = () => { + runAuthQuery() + runEdgeQuery() + } return (
@@ -144,6 +151,15 @@ export const UserJourney = ({ user }: UserJourneyProps) => {
+ } + onClick={() => setShowSample((value) => !value)} + tooltip={{ + content: { text: showSample ? 'Showing sample data' : 'Show sample data' }, + }} + /> { variant="default" className="px-2" icon={} - onClick={() => setErrorsOnly(false)} + loading={isLoading} + disabled={isLoading || showSample} + onClick={refresh} tooltip={{ content: { text: 'Refresh' } }} />
- {events.length === 0 ? ( + {isLoading && events.length === 0 ? ( + + ) : error && events.length === 0 && !showSample ? ( + + ) : events.length === 0 ? ( {

{event.error.message}

-

- policy - - {event.error.policy} - - on table - - {event.error.table} - -

+ {(event.error.policy || event.error.table) && ( +

+ {event.error.policy ? ( + <> + policy + + {event.error.policy} + + + ) : ( + + policy name unavailable from logs + + )} + {event.error.table && ( + <> + on table + + {event.error.table} + + + )} +

+ )}
)} diff --git a/apps/studio/components/interfaces/Auth/Users/UserJourney.utils.test.ts b/apps/studio/components/interfaces/Auth/Users/UserJourney.utils.test.ts new file mode 100644 index 00000000000..09e1eec4baa --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UserJourney.utils.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' + +import { + buildAuthLogsSql, + buildEdgeLogsSql, + buildJourney, + classifyAuthLog, + classifyEdgeLog, + isValidUserId, +} from './UserJourney.utils' +import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types' + +const USER_ID = '00000000-0000-4000-8000-000000000000' + +const authRow = (overrides: Partial = {}): LogData => + ({ id: 'a1', timestamp: 1_700_000_000_000_000, event_message: '', ...overrides }) as LogData + +const edgeRow = (overrides: Partial = {}): LogData => + ({ id: 'e1', timestamp: 1_700_000_000_000_000, event_message: '', ...overrides }) as LogData + +describe('isValidUserId', () => { + it('accepts a uuid', () => { + expect(isValidUserId(USER_ID)).toBe(true) + }) + + it('rejects empty, null, and non-uuid values', () => { + expect(isValidUserId(undefined)).toBe(false) + expect(isValidUserId(null)).toBe(false) + expect(isValidUserId('')).toBe(false) + expect(isValidUserId('not-a-uuid')).toBe(false) + }) +}) + +describe('buildAuthLogsSql', () => { + it('targets auth_logs and filters by user in BigQuery dialect', () => { + const sql = buildAuthLogsSql(USER_ID, false) + expect(sql).toContain('from auth_logs') + expect(sql).toContain(`regexp_contains(event_message, '${USER_ID}')`) + }) + + it('targets the ClickHouse logs table with a source filter in OTEL dialect', () => { + const sql = buildAuthLogsSql(USER_ID, true) + expect(sql).toContain("where source = 'auth_logs'") + expect(sql).toContain(`ilike '%${USER_ID}%'`) + }) +}) + +describe('buildEdgeLogsSql', () => { + it('filters edge logs on auth_user in BigQuery dialect', () => { + const sql = buildEdgeLogsSql(USER_ID, false) + expect(sql).toContain('from edge_logs') + expect(sql).toContain(`sb.auth_user = '${USER_ID}'`) + }) + + it('filters edge logs on the auth_user attribute in OTEL dialect', () => { + const sql = buildEdgeLogsSql(USER_ID, true) + expect(sql).toContain("where source = 'edge_logs'") + expect(sql).toContain(`log_attributes['request.sb.auth_user'] = '${USER_ID}'`) + }) +}) + +describe('classifyAuthLog', () => { + it('labels a signup event as a success', () => { + const event = classifyAuthLog(authRow({ path: '/signup', msg: 'user signed up' })) + expect(event).toMatchObject({ source: 'auth', status: 'success', title: 'Signed up' }) + }) + + it('labels a token/login event as Authenticated', () => { + const event = classifyAuthLog(authRow({ path: '/token', msg: 'login' })) + expect(event.title).toBe('Authenticated') + }) + + it('marks error/fatal levels as errors and attaches an error object', () => { + const event = classifyAuthLog(authRow({ level: 'error', msg: 'invalid password' })) + expect(event.status).toBe('error') + expect(event.error?.message).toBe('invalid password') + }) +}) + +describe('classifyEdgeLog', () => { + it('labels a successful GET as a neutral read', () => { + const event = classifyEdgeLog( + edgeRow({ method: 'GET', path: '/rest/v1/profiles', status_code: '200' }) + ) + expect(event).toMatchObject({ + source: 'postgrest', + status: 'neutral', + title: 'Read profiles', + }) + expect(event.request).toEqual({ method: 'GET', path: '/rest/v1/profiles', statusCode: 200 }) + }) + + it('labels a successful POST as a neutral create', () => { + const event = classifyEdgeLog( + edgeRow({ method: 'POST', path: '/rest/v1/orders', status_code: '201' }) + ) + expect(event.title).toBe('Created orders') + expect(event.status).toBe('neutral') + }) + + it('treats a 403 write as a blocked error and infers the table but not the policy', () => { + const event = classifyEdgeLog( + edgeRow({ method: 'POST', path: '/rest/v1/payments', status_code: '403' }) + ) + expect(event.status).toBe('error') + expect(event.title).toBe('Write blocked') + expect(event.error?.table).toBe('payments') + expect(event.error?.policy).toBeUndefined() + expect(event.error?.policyUnknown).toBe(true) + }) + + it('handles a query string in the path when inferring the resource', () => { + const event = classifyEdgeLog( + edgeRow({ method: 'GET', path: '/rest/v1/orders?select=*&id=eq.1', status_code: '200' }) + ) + expect(event.title).toBe('Read orders') + }) +}) + +describe('buildJourney', () => { + it('merges auth and edge events into a single oldest-to-newest timeline', () => { + const authLogs = [ + authRow({ id: 'signup', timestamp: 100, path: '/signup', msg: 'signed up' }), + authRow({ id: 'login', timestamp: 200, path: '/token', msg: 'login' }), + ] + const edgeLogs = [ + edgeRow({ + id: 'read', + timestamp: 300, + method: 'GET', + path: '/rest/v1/p', + status_code: '200', + }), + edgeRow({ + id: 'blocked', + timestamp: 400, + method: 'POST', + path: '/rest/v1/payments', + status_code: '403', + }), + ] + + const journey = buildJourney(authLogs, edgeLogs) + + expect(journey.map((event) => event.id)).toEqual(['signup', 'login', 'read', 'blocked']) + expect(journey[journey.length - 1].status).toBe('error') + }) +}) diff --git a/apps/studio/components/interfaces/Auth/Users/UserJourney.utils.ts b/apps/studio/components/interfaces/Auth/Users/UserJourney.utils.ts new file mode 100644 index 00000000000..846db523205 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UserJourney.utils.ts @@ -0,0 +1,244 @@ +/** + * UserJourney.utils — pure helpers for the User Journey tab. + * + * These build the logs SQL, classify raw auth/edge log rows into journey + * events, and merge them chronologically. Kept free of React so they can be + * unit-tested in isolation (see studio-testing conventions). + * + * DATA-AVAILABILITY NOTE: the logs give us the auth timeline (auth_logs) and + * per-user API activity with method/path/status (edge_logs, filtered on the + * caller's JWT `auth_user`). What they do NOT give us is the exact RLS policy + * that denied a write — Postgres' RLS error names the table, never the policy, + * and there's no request id joining an edge_logs row to a postgres_logs row. + * So a 403 on a `/rest/v1/*` write is surfaced as "likely blocked by RLS" with + * the table inferred from the path, and the policy name is left unknown. + */ +import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types' + +export type JourneyEventStatus = 'success' | 'neutral' | 'error' +export type JourneyEventSource = 'auth' | 'postgrest' + +export interface JourneyEvent { + id: string + /** ISO string (sample data) or unix microseconds (live logs) — both render via TimestampInfo. */ + timestamp: string | number + source: JourneyEventSource + status: JourneyEventStatus + title: string + description: string + request?: { method: string; path: string; statusCode: number } + error?: { message: string; policy?: string; table?: string; policyUnknown?: boolean } +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export const isValidUserId = (userId?: string | null): userId is string => + !!userId && UUID_RE.test(userId) + +/** Last meaningful path segment, e.g. `/rest/v1/orders?select=*` → `orders`. */ +const resourceFromPath = (path: string) => { + const clean = path.split('?')[0].replace(/\/+$/, '') + const segments = clean.split('/').filter(Boolean) + return segments[segments.length - 1] || clean || 'resource' +} + +/** + * SQL to pull this user's auth events. Matched on the user id appearing in the + * raw event message — the same heuristic the Logs tab uses (`search_query`). + */ +export const buildAuthLogsSql = (userId: string, useOtel: boolean, limit = 50) => { + if (useOtel) { + return `select + id, + timestamp, + event_message, + log_attributes['level'] as level, + log_attributes['status'] as status, + log_attributes['path'] as path, + log_attributes['msg'] as msg +from logs +where source = 'auth_logs' + and event_message ilike '%${userId}%' +order by timestamp desc +limit ${limit}` + } + return `select + id, + timestamp, + event_message, + metadata.level as level, + metadata.status as status, + metadata.path as path, + metadata.msg as msg +from auth_logs +cross join unnest(metadata) as metadata +where regexp_contains(event_message, '${userId}') +order by timestamp desc +limit ${limit}` +} + +/** + * SQL to pull this user's API activity from the edge gateway, filtered on the + * JWT `auth_user` (the acting user's id) carried on each request. + * + * The OTEL/ClickHouse `log_attributes` key for auth_user is the BigQuery path + * with the `metadata` root dropped; if it ever returns nothing on a ClickHouse + * project, confirm the key via the Field Reference drawer / `mapKeys`. + */ +export const buildEdgeLogsSql = (userId: string, useOtel: boolean, limit = 50) => { + if (useOtel) { + return `select + id, + timestamp, + log_attributes['request.method'] as method, + log_attributes['request.path'] as path, + log_attributes['response.status_code'] as status_code +from logs +where source = 'edge_logs' + and log_attributes['request.sb.auth_user'] = '${userId}' +order by timestamp desc +limit ${limit}` + } + return `select + id, + timestamp, + request.method as method, + request.path as path, + response.status_code as status_code +from edge_logs +cross join unnest(metadata) as m +cross join unnest(m.request) as request +cross join unnest(m.response) as response +cross join unnest(request.sb) as sb +where sb.auth_user = '${userId}' +order by timestamp desc +limit ${limit}` +} + +const toSortableTimestamp = (timestamp: string | number) => + typeof timestamp === 'number' ? timestamp : Date.parse(timestamp) + +export const classifyAuthLog = (log: LogData): JourneyEvent => { + const msg = String(log.msg ?? log.event_message ?? '') + const path = String(log.path ?? '') + const level = String(log.level ?? '').toLowerCase() + const haystack = `${msg} ${path}`.toLowerCase() + const isError = level === 'error' || level === 'fatal' + + let title = 'Authentication event' + if (haystack.includes('signup') || haystack.includes('sign up')) title = 'Signed up' + else if (haystack.includes('token') || haystack.includes('login')) title = 'Authenticated' + else if (haystack.includes('logout')) title = 'Signed out' + else if (haystack.includes('recover')) title = 'Password recovery' + + return { + id: String(log.id), + timestamp: log.timestamp, + source: 'auth', + status: isError ? 'error' : 'success', + title, + description: `Auth · ${msg || path || 'event'}`, + ...(isError && { error: { message: msg || 'Authentication error' } }), + } +} + +export const classifyEdgeLog = (log: LogData): JourneyEvent => { + const method = String(log.method ?? '').toUpperCase() + const path = String(log.path ?? '') + const statusCode = Number(log.status_code ?? 0) + const resource = resourceFromPath(path) + const isError = statusCode >= 400 + const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) + + let title: string + if (isError) title = isWrite ? 'Write blocked' : 'Request failed' + else if (method === 'GET') title = `Read ${resource}` + else if (method === 'POST') title = `Created ${resource}` + else if (method === 'PATCH' || method === 'PUT') title = `Updated ${resource}` + else if (method === 'DELETE') title = `Deleted ${resource}` + else title = `${method || 'Request'} ${resource}`.trim() + + const event: JourneyEvent = { + id: String(log.id), + timestamp: log.timestamp, + source: 'postgrest', + status: isError ? 'error' : 'neutral', + title, + description: `PostgREST · ${method || 'request'}`, + ...(path && { request: { method, path, statusCode } }), + } + + if (isError) { + const likelyRls = statusCode === 403 && path.includes('/rest/') + event.error = { + message: likelyRls + ? 'Request denied — likely blocked by a row-level security policy' + : `Request failed with status ${statusCode}`, + ...(likelyRls && { table: resource, policyUnknown: true }), + } + } + + return event +} + +/** Merge auth + edge rows into a single chronological (oldest → newest) timeline. */ +export const buildJourney = (authLogs: LogData[], edgeLogs: LogData[]): JourneyEvent[] => { + return [...authLogs.map(classifyAuthLog), ...edgeLogs.map(classifyEdgeLog)].sort( + (a, b) => toSortableTimestamp(a.timestamp) - toSortableTimestamp(b.timestamp) + ) +} + +/** + * Hardcoded reference scenario (mirrors a real support case). Used by the + * "Sample data" toggle so the tab is demoable on projects with no matching logs. + */ +export const SAMPLE_JOURNEY_EVENTS: JourneyEvent[] = [ + { + id: 'sample-1', + timestamp: '2026-07-13T09:41:02.118Z', + source: 'auth', + status: 'success', + title: 'Signed up', + description: 'Auth · new user created via email signup', + }, + { + id: 'sample-2', + timestamp: '2026-07-13T09:41:02.421Z', + source: 'auth', + status: 'success', + title: 'Authenticated', + description: 'Auth · session issued, JWT minted', + }, + { + id: 'sample-3', + timestamp: '2026-07-13T09:41:03.905Z', + source: 'postgrest', + status: 'neutral', + title: 'Read profile', + description: 'PostgREST · fetched the current user profile', + request: { method: 'GET', path: '/rest/v1/profiles', statusCode: 200 }, + }, + { + id: 'sample-4', + timestamp: '2026-07-13T09:41:05.332Z', + source: 'postgrest', + status: 'neutral', + title: 'Created order', + description: 'PostgREST · inserted a new order row', + request: { method: 'POST', path: '/rest/v1/orders', statusCode: 201 }, + }, + { + id: 'sample-5', + timestamp: '2026-07-13T09:41:06.744Z', + source: 'postgrest', + status: 'error', + title: 'Write blocked', + description: 'PostgREST · insert rejected before reaching the table', + request: { method: 'POST', path: '/rest/v1/payments', statusCode: 403 }, + error: { + message: 'Denied by RLS policy', + policy: 'payments_insert_owner', + table: 'payments', + }, + }, +]