diff --git a/apps/backend/src/app/api/latest/internal/session-replays/route.tsx b/apps/backend/src/app/api/latest/internal/session-replays/route.tsx index 45f45290b6..e3c3d3b9b9 100644 --- a/apps/backend/src/app/api/latest/internal/session-replays/route.tsx +++ b/apps/backend/src/app/api/latest/internal/session-replays/route.tsx @@ -1,11 +1,82 @@ -import { getPrismaClientForTenancy } from "@/prisma-client"; +import { getClickhouseExternalClient } from "@/lib/clickhouse"; +import { Prisma } from "@/generated/prisma/client"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { BooleanTrue, Prisma } from "@/generated/prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; +const CLICK_FILTER_ID_CAP = 1000; + +function parseCsvIds(raw: string | undefined): string[] { + if (!raw) return []; + return raw.split(",").map(s => s.trim()).filter(Boolean); +} + +function parseCsvUuids(name: string, raw: string | undefined): string[] { + const values = parseCsvIds(raw); + for (const value of values) { + if (!isUuid(value)) { + throw new StatusError(StatusError.BadRequest, `${name} must contain valid UUID values`); + } + } + return values; +} + +function parseNonNegativeInt(name: string, raw: string | undefined): number | null { + if (!raw) return null; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) { + throw new StatusError(StatusError.BadRequest, `${name} must be a non-negative integer`); + } + return value; +} + +function parseMillis(name: string, raw: string | undefined): Date | null { + if (!raw) return null; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) { + throw new StatusError(StatusError.BadRequest, `${name} must be a non-negative timestamp in milliseconds`); + } + return new Date(value); +} + +async function loadClickQualifiedReplayIds(options: { + projectId: string, + branchId: string, + clickCountMin: number, +}): Promise { + const clickhouseClient = getClickhouseExternalClient(); + const result = await clickhouseClient.query({ + query: ` + SELECT session_replay_id + FROM default.events + WHERE project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_type = '$click' + GROUP BY session_replay_id + HAVING count() >= {clickCountMin:UInt64} + LIMIT {cap:UInt64} + `, + query_params: { + projectId: options.projectId, + branchId: options.branchId, + clickCountMin: options.clickCountMin, + cap: CLICK_FILTER_ID_CAP, + }, + clickhouse_settings: { + SQL_project_id: options.projectId, + SQL_branch_id: options.branchId, + }, + format: "JSONEachRow", + }); + + const rows = await result.json() as Array<{ session_replay_id: string }>; + return rows.map((row) => row.session_replay_id); +} export const GET = createSmartRouteHandler({ metadata: { hidden: true }, @@ -17,6 +88,13 @@ export const GET = createSmartRouteHandler({ query: yupObject({ cursor: yupString().optional(), limit: yupString().optional(), + user_ids: yupString().optional(), + team_ids: yupString().optional(), + duration_ms_min: yupString().optional(), + duration_ms_max: yupString().optional(), + last_event_at_from_millis: yupString().optional(), + last_event_at_to_millis: yupString().optional(), + click_count_min: yupString().optional(), }).optional(), }), response: yupObject({ @@ -42,69 +120,120 @@ export const GET = createSmartRouteHandler({ }), async handler({ auth, query }) { const prisma = await getPrismaClientForTenancy(auth.tenancy); + const schema = await getPrismaSchemaForTenancy(auth.tenancy); const rawLimit = query.limit ?? String(DEFAULT_LIMIT); const parsedLimit = Number.parseInt(rawLimit, 10); const limit = Math.max(1, Math.min(MAX_LIMIT, Number.isFinite(parsedLimit) ? parsedLimit : DEFAULT_LIMIT)); + const userIdsFilter = parseCsvUuids("user_ids", query.user_ids); + const teamIdsFilter = parseCsvUuids("team_ids", query.team_ids); + const durationMsMin = parseNonNegativeInt("duration_ms_min", query.duration_ms_min); + const durationMsMax = parseNonNegativeInt("duration_ms_max", query.duration_ms_max); + const clickCountMin = parseNonNegativeInt("click_count_min", query.click_count_min); + const lastEventAtFrom = parseMillis("last_event_at_from_millis", query.last_event_at_from_millis); + const lastEventAtTo = parseMillis("last_event_at_to_millis", query.last_event_at_to_millis); + + if (durationMsMin !== null && durationMsMax !== null && durationMsMin > durationMsMax) { + throw new StatusError(StatusError.BadRequest, "duration_ms_min must be less than or equal to duration_ms_max"); + } + if (lastEventAtFrom && lastEventAtTo && lastEventAtFrom.getTime() > lastEventAtTo.getTime()) { + throw new StatusError(StatusError.BadRequest, "last_event_at_from_millis must be less than or equal to last_event_at_to_millis"); + } + + // If click filter is active, get qualifying replay IDs from ClickHouse in one query + const clickQualifiedIds = clickCountMin && clickCountMin > 0 + ? await loadClickQualifiedReplayIds({ + projectId: auth.tenancy.project.id, + branchId: auth.tenancy.branchId, + clickCountMin, + }) + : null; + + if (clickQualifiedIds && clickQualifiedIds.length === 0) { + return { + statusCode: 200, + bodyType: "json", + body: { items: [], pagination: { next_cursor: null } }, + }; + } + + // Handle cursor-based pagination const cursorId = query.cursor; - let cursorPivot: { lastEventAt: Date } | null = null; + let cursorPivot: { id: string, lastEventAt: Date } | null = null; if (cursorId) { cursorPivot = await prisma.sessionReplay.findUnique({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } }, - select: { lastEventAt: true }, + select: { id: true, lastEventAt: true }, }); if (!cursorPivot) { throw new KnownErrors.ItemNotFound(cursorId); } } - const where: Prisma.SessionReplayWhereInput = cursorId && cursorPivot ? { - OR: [ - { lastEventAt: { lt: cursorPivot.lastEventAt } }, - { AND: [{ lastEventAt: { equals: cursorPivot.lastEventAt } }, { id: { lt: cursorId } }] }, - ], - } : {}; - - const sessions = await prisma.sessionReplay.findMany({ - where: { tenancyId: auth.tenancy.id, ...where }, - orderBy: [{ lastEventAt: "desc" }, { id: "desc" }], - take: limit + 1, - select: { - id: true, - projectUserId: true, - startedAt: true, - lastEventAt: true, - }, - }); + type ReplayRow = { + id: string, + projectUserId: string, + startedAt: Date, + lastEventAt: Date, + projectUserDisplayName: string | null, + primaryEmail: string | null, + }; - const hasMore = sessions.length > limit; - const page = hasMore ? sessions.slice(0, limit) : sessions; - const nextCursor = hasMore ? page[page.length - 1]!.id : null; + const rows = await prisma.$queryRaw` + SELECT + sr."id", + sr."projectUserId", + sr."startedAt", + sr."lastEventAt", + pu."displayName" AS "projectUserDisplayName", + ( + SELECT cc."value" + FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc + WHERE cc."projectUserId" = sr."projectUserId" + AND cc."tenancyId" = sr."tenancyId" + AND cc."type" = 'EMAIL' + AND cc."isPrimary" = 'TRUE'::"BooleanTrue" + LIMIT 1 + ) AS "primaryEmail" + FROM ${sqlQuoteIdent(schema)}."SessionReplay" sr + JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu + ON pu."projectUserId" = sr."projectUserId" + AND pu."tenancyId" = sr."tenancyId" + WHERE sr."tenancyId" = ${auth.tenancy.id}::UUID + ${userIdsFilter.length > 0 ? Prisma.sql`AND sr."projectUserId" IN (${Prisma.join(userIdsFilter)})` : Prisma.empty} + ${lastEventAtFrom ? Prisma.sql`AND sr."lastEventAt" >= ${lastEventAtFrom}` : Prisma.empty} + ${lastEventAtTo ? Prisma.sql`AND sr."lastEventAt" <= ${lastEventAtTo}` : Prisma.empty} + ${teamIdsFilter.length > 0 ? Prisma.sql`AND EXISTS ( + SELECT 1 FROM ${sqlQuoteIdent(schema)}."TeamMember" tm + WHERE tm."projectUserId" = sr."projectUserId" + AND tm."tenancyId" = sr."tenancyId" + AND tm."teamId" IN (${Prisma.join(teamIdsFilter)}) + )` : Prisma.empty} + ${clickQualifiedIds ? Prisma.sql`AND sr."id" IN (${Prisma.join(clickQualifiedIds)})` : Prisma.empty} + ${durationMsMin !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 >= ${durationMsMin}` : Prisma.empty} + ${durationMsMax !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 <= ${durationMsMax}` : Prisma.empty} + ${cursorPivot ? Prisma.sql`AND ( + sr."lastEventAt" < ${cursorPivot.lastEventAt} + OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId}) + )` : Prisma.empty} + ORDER BY sr."lastEventAt" DESC, sr."id" DESC + LIMIT ${limit + 1} + `; - const sessionIds = page.map(s => s.id); - const userIds = [...new Set(page.map(s => s.projectUserId))]; + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? page[page.length - 1]!.id : null; - const [chunkAggs, users] = await Promise.all([ - sessionIds.length ? prisma.sessionReplayChunk.groupBy({ + const sessionIds = page.map((row) => row.id); + const chunkAggs = sessionIds.length + ? await prisma.sessionReplayChunk.groupBy({ by: ["sessionReplayId"], where: { tenancyId: auth.tenancy.id, sessionReplayId: { in: sessionIds } }, _count: { _all: true }, _sum: { eventCount: true }, - }) : Promise.resolve([] as Array<{ sessionReplayId: string, _count: { _all: number }, _sum: { eventCount: number | null } }>), - userIds.length ? prisma.projectUser.findMany({ - where: { tenancyId: auth.tenancy.id, projectUserId: { in: userIds } }, - select: { - projectUserId: true, - displayName: true, - contactChannels: { - where: { type: "EMAIL", isPrimary: BooleanTrue.TRUE }, - select: { value: true }, - take: 1, - }, - }, - }) : Promise.resolve([] as Array<{ projectUserId: string, displayName: string | null, contactChannels: Array<{ value: string }> }>), - ]); + }) + : []; const aggBySessionId = new Map(); for (const a of chunkAggs) { @@ -114,30 +243,21 @@ export const GET = createSmartRouteHandler({ }); } - const userById = new Map(); - for (const u of users) { - userById.set(u.projectUserId, { - displayName: u.displayName, - primaryEmail: u.contactChannels[0]?.value ?? null, - }); - } - return { statusCode: 200, bodyType: "json", body: { - items: page.map((s) => { - const user = userById.get(s.projectUserId); - const agg = aggBySessionId.get(s.id) ?? { chunkCount: 0, eventCount: 0 }; + items: page.map((row) => { + const agg = aggBySessionId.get(row.id) ?? { chunkCount: 0, eventCount: 0 }; return { - id: s.id, + id: row.id, project_user: { - id: s.projectUserId, - display_name: user?.displayName ?? null, - primary_email: user?.primaryEmail ?? null, + id: row.projectUserId, + display_name: row.projectUserDisplayName ?? null, + primary_email: row.primaryEmail ?? null, }, - started_at_millis: s.startedAt.getTime(), - last_event_at_millis: s.lastEventAt.getTime(), + started_at_millis: row.startedAt.getTime(), + last_event_at_millis: row.lastEventAt.getTime(), chunk_count: agg.chunkCount, event_count: agg.eventCount, }; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 14f503db06..c3c5de0fc5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -1,6 +1,8 @@ "use client"; import { Alert, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Skeleton, Switch, Typography } from "@/components/ui"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { StyledLink } from "@/components/link"; import { useFromNow } from "@/hooks/use-from-now"; import { getDesiredGlobalOffsetFromPlaybackState, @@ -16,9 +18,11 @@ import { import { cn } from "@/lib/utils"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, FunnelSimpleIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon, XIcon } from "@phosphor-icons/react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { UserSearchPicker } from "@/components/data-table/user-search-picker"; +import { TeamSearchTable } from "@/components/data-table/team-search-table"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; @@ -69,7 +73,17 @@ type ChunkRow = { }; type AdminAppWithSessionReplays = ReturnType & { - listSessionReplays: (options?: { limit?: number, cursor?: string }) => Promise<{ + listSessionReplays: (options?: { + limit?: number, + cursor?: string, + userIds?: string[], + teamIds?: string[], + durationMsMin?: number, + durationMsMax?: number, + lastEventAtFromMillis?: number, + lastEventAtToMillis?: number, + clickCountMin?: number, + }) => Promise<{ items: RecordingRow[], nextCursor: string | null, }>, @@ -79,6 +93,28 @@ type AdminAppWithSessionReplays = ReturnType & { }>, }; +type ReplayFilters = { + userId: string, + userLabel: string, + teamId: string, + teamLabel: string, + durationMinSeconds: string, + durationMaxSeconds: string, + lastActivePreset: "" | "24h" | "7d" | "30d", + clickCountMin: string, +}; + +const EMPTY_FILTERS: ReplayFilters = { + userId: "", + userLabel: "", + teamId: "", + teamLabel: "", + durationMinSeconds: "", + durationMaxSeconds: "", + lastActivePreset: "", + clickCountMin: "", +}; + function coerceRrwebEvents(raw: unknown[]): RrwebEventWithTime[] { const filtered: Array<{ timestamp: number }> = []; for (const e of raw) { @@ -113,6 +149,16 @@ function formatTimelineMs(ms: number) { return `${m}:${s.toString().padStart(2, "0")}`; } +function filtersActiveCount(filters: ReplayFilters): number { + let count = 0; + if (filters.userId) count += 1; + if (filters.teamId) count += 1; + if (filters.durationMinSeconds || filters.durationMaxSeconds) count += 1; + if (filters.lastActivePreset) count += 1; + if (filters.clickCountMin) count += 1; + return count; +} + type TimelineEvent = { eventType: string, eventAtMs: number, @@ -417,13 +463,16 @@ function useReplayMachine(initialSettings: ReplaySettings) { export default function PageClient() { const adminApp = useAdminApp() as AdminAppWithSessionReplays; - // ---- Recording list state (unchanged from original) ---- + // ---- Recording list + filters ---- const [recordings, setRecordings] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [loadingInitial, setLoadingInitial] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [listError, setListError] = useState(null); + const [activeFilterDialog, setActiveFilterDialog] = useState(null); + const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS); + const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); const [clickCountsByReplayId, setClickCountsByReplayId] = useState>(new Map()); const [timelineEvents, setTimelineEvents] = useState([]); @@ -436,39 +485,57 @@ export default function PageClient() { ); const hasAutoSelectedRef = useRef(false); - const hasFetchedInitialRef = useRef(false); + const loadingMoreRef = useRef(false); const loadPage = useCallback(async (cursor: string | null) => { - if (cursor === null && hasFetchedInitialRef.current) return; - if (cursor === null) hasFetchedInitialRef.current = true; - if (cursor !== null && loadingMore) return; + if (cursor !== null && loadingMoreRef.current) return; if (cursor === null) { setLoadingInitial(true); } else { + loadingMoreRef.current = true; setLoadingMore(true); } setListError(null); try { - const res = await adminApp.listSessionReplays({ limit: PAGE_SIZE, cursor: cursor ?? undefined }); - const items = cursor ? [...recordings, ...res.items] : res.items; - setRecordings(items); + const presetMs: Record = { "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 }; + const lastActiveFromMillis = appliedFilters.lastActivePreset && presetMs[appliedFilters.lastActivePreset] + ? Date.now() - presetMs[appliedFilters.lastActivePreset] + : undefined; + + const res = await adminApp.listSessionReplays({ + limit: PAGE_SIZE, + cursor: cursor ?? undefined, + userIds: appliedFilters.userId ? [appliedFilters.userId] : undefined, + teamIds: appliedFilters.teamId ? [appliedFilters.teamId] : undefined, + durationMsMin: appliedFilters.durationMinSeconds ? Number(appliedFilters.durationMinSeconds) * 1000 : undefined, + durationMsMax: appliedFilters.durationMaxSeconds ? Number(appliedFilters.durationMaxSeconds) * 1000 : undefined, + lastEventAtFromMillis: lastActiveFromMillis, + clickCountMin: appliedFilters.clickCountMin ? Number(appliedFilters.clickCountMin) : undefined, + }); + setRecordings((prev) => { + const items = cursor ? [...prev, ...res.items] : res.items; + if (!cursor && !hasAutoSelectedRef.current && items.length > 0) { + hasAutoSelectedRef.current = true; + setSelectedRecordingId(items[0].id); + } + return items; + }); setNextCursor(res.nextCursor); - - if (!cursor && !hasAutoSelectedRef.current && items.length > 0) { - hasAutoSelectedRef.current = true; - setSelectedRecordingId(items[0].id); - } } catch (e: any) { setListError(e?.message ?? "Failed to load session recordings."); } finally { setLoadingInitial(false); + loadingMoreRef.current = false; setLoadingMore(false); } - }, [adminApp, loadingMore, recordings]); + }, [adminApp, appliedFilters]); useEffect(() => { + setRecordings([]); + setNextCursor(null); + hasAutoSelectedRef.current = false; runAsynchronously(() => loadPage(null), { noErrorLogging: true }); }, [loadPage]); @@ -498,12 +565,12 @@ export default function PageClient() { const el = listBoxRef.current; if (!el) return; if (!nextCursor) return; - if (loadingMore || loadingInitial) return; + if (loadingMoreRef.current || loadingInitial) return; const remaining = el.scrollHeight - el.scrollTop - el.clientHeight; if (remaining < 200) { runAsynchronously(() => loadPage(nextCursor), { noErrorLogging: true }); } - }, [loadingInitial, loadingMore, loadPage, nextCursor]); + }, [loadingInitial, loadPage, nextCursor]); // ---- Replay state machine ---- @@ -617,6 +684,7 @@ export default function PageClient() { root: rootEl, speed: msRef.current.settings.playerSpeed, skipInactive: msRef.current.settings.skipInactivity, + triggerFocus: false, }); rootEl.style.position = "relative"; @@ -1290,6 +1358,27 @@ export default function PageClient() { })).filter(m => m.timeMs >= 0 && m.timeMs <= ms.globalTotalMs); }, [timelineEvents, ms.globalStartTs, ms.globalTotalMs]); + const activeFilterCount = useMemo(() => filtersActiveCount(appliedFilters), [appliedFilters]); + + const openFilterDialog = useCallback((dialog: "user" | "team" | "duration" | "lastActive" | "clicks") => { + setDraftFilters(appliedFilters); + setActiveFilterDialog(dialog); + }, [appliedFilters]); + + const applyDraftFilters = useCallback(() => { + setAppliedFilters(draftFilters); + setActiveFilterDialog(null); + }, [draftFilters]); + + useEffect(() => { + if (recordings.length === 0) { + setSelectedRecordingId(null); + return; + } + if (selectedRecordingId && recordings.some((r) => r.id === selectedRecordingId)) return; + setSelectedRecordingId(recordings[0]?.id ?? null); + }, [recordings, selectedRecordingId]); + // ---- Rendering ---- return ( @@ -1298,12 +1387,274 @@ export default function PageClient() {
-
- - Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""} - +
+
+ + Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""} + + + + + + e.preventDefault()} + > + { requestAnimationFrame(() => openFilterDialog("user")); }}> + User + + { requestAnimationFrame(() => openFilterDialog("team")); }}> + Team + + { requestAnimationFrame(() => openFilterDialog("duration")); }}> + Duration + + { requestAnimationFrame(() => openFilterDialog("lastActive")); }}> + Last active + + { requestAnimationFrame(() => openFilterDialog("clicks")); }}> + Click count + + + +
+ + {activeFilterCount > 0 && ( +
+ {appliedFilters.userId && ( + + user:{appliedFilters.userLabel || "selected"} + + )} + {appliedFilters.teamId && ( + + team:{appliedFilters.teamLabel || "selected"} + + )} + {(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && ( + + duration + + )} + {appliedFilters.lastActivePreset && ( + + last active: {appliedFilters.lastActivePreset} + + )} + {appliedFilters.clickCountMin && ( + + clicks + + )} + +
+ )}
+ setActiveFilterDialog(open ? "user" : null)}> + + + User Filter + +
+ ( + + )} + /> +
+ +
+
+
+
+ + setActiveFilterDialog(open ? "team" : null)}> + + + Team Filter + +
+ ( + + )} + /> +
+ +
+
+
+
+ + setActiveFilterDialog(open ? "duration" : null)}> + + + Duration Filter + +
{ + e.preventDefault(); + applyDraftFilters(); + }}> +
+ + +
+
+ + +
+
+
+
+ + setActiveFilterDialog(open ? "lastActive" : null)}> + + + Last Active Filter + +
+ {([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => ( + + ))} +
+
+ +
+
+
+ + setActiveFilterDialog(open ? "clicks" : null)}> + + + Click Count Filter + +
{ + e.preventDefault(); + applyDraftFilters(); + }}> +
+ setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))} + placeholder="Minimum click count" + /> +
+
+ + +
+
+
+
+ {listError && (
{listError} @@ -1327,7 +1678,7 @@ export default function PageClient() { ) : recordings.length === 0 ? (
- No replays yet. + {activeFilterCount > 0 ? "No replays match these filters." : "No replays yet."}
) : ( @@ -1391,9 +1742,16 @@ export default function PageClient() { )}
- - {selectedRecording ? getRecordingTitle(selectedRecording) : ""} - + {selectedRecording ? ( + + {getRecordingTitle(selectedRecording)} + + ) : ( + + )} actRef.current({ type: "UPDATE_SETTINGS", updates })} diff --git a/apps/dashboard/src/components/data-table/user-search-picker.tsx b/apps/dashboard/src/components/data-table/user-search-picker.tsx new file mode 100644 index 0000000000..d23c74bec1 --- /dev/null +++ b/apps/dashboard/src/components/data-table/user-search-picker.tsx @@ -0,0 +1,103 @@ +'use client'; +import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app'; +import { DataTableColumnHeader, DataTableManualPagination, Input, Skeleton, TextCell } from "@/components/ui"; +import { ServerUser } from '@stackframe/stack'; +import { ColumnDef, ColumnFiltersState, SortingState } from "@tanstack/react-table"; +import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { extendUsers } from "./user-table"; + +const SEARCH_DEBOUNCE_MS = 250; +const PAGE_SIZE = 5; + +function UserSearchTable(props: { + query: string, + action: (user: ServerUser) => React.ReactNode, +}) { + const stackAdminApp = useAdminApp(); + const [filters, setFilters] = useState[0]>({ + limit: PAGE_SIZE, + query: props.query || undefined, + }); + + // Reset filters when query changes + useEffect(() => { + setFilters({ limit: PAGE_SIZE, query: props.query || undefined }); + }, [props.query]); + + const users = extendUsers(stackAdminApp.useUsers(filters)); + + const { action } = props; + const columns: ColumnDef[] = useMemo(() => [ + { + accessorKey: "displayName", + header: ({ column }) => , + cell: ({ row }) => {row.original.displayName ?? '–'}, + enableSorting: false, + }, + { + accessorKey: "primaryEmail", + header: ({ column }) => , + cell: ({ row }) => {row.original.primaryEmail}, + enableSorting: false, + }, + { + id: "actions", + cell: ({ row }) => action(row.original), + }, + ], [action]); + + const onUpdate = useCallback(async (options: { + cursor: string, + limit: number, + sorting: SortingState, + columnFilters: ColumnFiltersState, + globalFilters: any, + }) => { + const newFilters: Parameters[0] = { + cursor: options.cursor, + limit: options.limit, + query: props.query || undefined, + }; + + setFilters(newFilters); + const result = await stackAdminApp.listUsers(newFilters); + return { nextCursor: result.nextCursor }; + }, [stackAdminApp, props.query]); + + return ; +} + +export function UserSearchPicker(props: { + action: (user: ServerUser) => React.ReactNode, +}) { + const [searchInput, setSearchInput] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedQuery(searchInput.trim()); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [searchInput]); + + return ( +
+ setSearchInput(e.target.value)} + className="h-8 text-xs" + /> +
}> + + +
+ ); +} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index d6b5247578..0c699b5a1c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../helpers"; -import { Auth, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; +import { Auth, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers"; async function uploadBatch(options: { browserSessionId: string, @@ -42,8 +43,19 @@ it("requires a user token", async ({ expect }) => { }, }); - expect(res.status).toBeGreaterThanOrEqual(400); - expect(res.status).toBeLessThan(500); + expect(res).toMatchInlineSnapshot(` + NiceResponse { + "status": 401, + "body": { + "code": "USER_AUTHENTICATION_REQUIRED", + "error": "User authentication required for this endpoint.", + }, + "headers": Headers { + "x-stack-known-error": "USER_AUTHENTICATION_REQUIRED", +