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',
+ },
+ },
+]