From 35a56f721f3f7ebd709de5d07e8776b5309ee332 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Wed, 5 Nov 2025 17:40:27 -0800 Subject: [PATCH] New table component (#995) ## Summary by CodeRabbit * **New Features** * Enhanced data table with cursor-based pagination, per-page caching and background prefetching for faster navigation * Pagination controls with page-size selector and Prev/Next navigation * Skeleton loading screens for smoother transitions * URL-synchronized query state with debounced search and shareable filters * Redesigned user management table with improved columns, actions (including copy-to-clipboard), filtering, sorting, and accessible controls --------- Co-authored-by: Konsti Wohlwend --- .../data-table/common/cursor-pagination.tsx | 50 + .../data-table/common/pagination.tsx | 77 ++ .../data-table/common/stable-value.tsx | 13 + .../data-table/common/table-skeleton.tsx | 69 + .../components/data-table/common/table.tsx | 119 ++ .../data-table/common/url-query-state.tsx | 93 ++ .../data-table/team-member-search-table.tsx | 2 +- .../data-table/team-member-table.tsx | 2 +- .../src/components/data-table/user-table.tsx | 1120 +++++++++++++---- 9 files changed, 1325 insertions(+), 220 deletions(-) create mode 100644 apps/dashboard/src/components/data-table/common/cursor-pagination.tsx create mode 100644 apps/dashboard/src/components/data-table/common/pagination.tsx create mode 100644 apps/dashboard/src/components/data-table/common/stable-value.tsx create mode 100644 apps/dashboard/src/components/data-table/common/table-skeleton.tsx create mode 100644 apps/dashboard/src/components/data-table/common/table.tsx create mode 100644 apps/dashboard/src/components/data-table/common/url-query-state.tsx diff --git a/apps/dashboard/src/components/data-table/common/cursor-pagination.tsx b/apps/dashboard/src/components/data-table/common/cursor-pagination.tsx new file mode 100644 index 000000000..16aff2981 --- /dev/null +++ b/apps/dashboard/src/components/data-table/common/cursor-pagination.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; + +export function useCursorPaginationCache(initialPage: number = 1) { + const cursorCacheRef = useRef(new Map([[initialPage, null]])); + const prefetchedCursorRef = useRef(new Set()); + + const resetCache = useCallback(() => { + cursorCacheRef.current = new Map([[initialPage, null]]); + prefetchedCursorRef.current.clear(); + }, [initialPage]); + + const readCursorForPage = useCallback((page: number): string | null | undefined => { + return cursorCacheRef.current.get(page); + }, []); + + const recordPageCursor = useCallback((page: number, cursor: string | null | undefined) => { + cursorCacheRef.current.set(page, cursor ?? null); + }, []); + + const recordNextCursor = useCallback((page: number, nextCursor: string | null | undefined) => { + if (nextCursor) { + cursorCacheRef.current.set(page + 1, nextCursor); + } else { + cursorCacheRef.current.delete(page + 1); + } + }, []); + + const prefetchCursor = useCallback((cursor: string | null | undefined, task: () => void | Promise) => { + if (!cursor) { + return; + } + if (prefetchedCursorRef.current.has(cursor)) { + return; + } + prefetchedCursorRef.current.add(cursor); + runAsynchronously(task()); + }, []); + + return { + resetCache, + readCursorForPage, + recordPageCursor, + recordNextCursor, + prefetchCursor, + }; +} + diff --git a/apps/dashboard/src/components/data-table/common/pagination.tsx b/apps/dashboard/src/components/data-table/common/pagination.tsx new file mode 100644 index 000000000..73938e016 --- /dev/null +++ b/apps/dashboard/src/components/data-table/common/pagination.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@stackframe/stack-ui"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; +import { combineClassNames } from "./table"; + +const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50]; + +type PaginationControlsProps = { + page: number, + pageSize: number, + hasNextPage: boolean, + hasPreviousPage: boolean, + onNextPage: () => void, + onPreviousPage: () => void, + onPageSizeChange: (pageSize: number) => void, + pageSizeOptions?: number[], + pageSizeLabel?: string, + pageIndicatorLabel?: (page: number) => ReactNode, + className?: string, +}; + +const defaultIndicator = (page: number) => <>Page {page}; + +export function PaginationControls(props: PaginationControlsProps) { + const { + page, + pageSize, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + onPageSizeChange, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + pageSizeLabel = "Rows per page", + pageIndicatorLabel = defaultIndicator, + className, + } = props; + + return ( +
+
+ {pageSizeLabel} + +
+
+ + + {pageIndicatorLabel(page)} + + +
+
+ ); +} + diff --git a/apps/dashboard/src/components/data-table/common/stable-value.tsx b/apps/dashboard/src/components/data-table/common/stable-value.tsx new file mode 100644 index 000000000..ef949c5aa --- /dev/null +++ b/apps/dashboard/src/components/data-table/common/stable-value.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useRef } from "react"; + +export function useStableValue(value: T, fingerprint: string): T { + const previousRef = useRef<{ fingerprint: string, value: T }>(); + if (previousRef.current && previousRef.current.fingerprint === fingerprint) { + return previousRef.current.value; + } + previousRef.current = { fingerprint, value }; + return value; +} + diff --git a/apps/dashboard/src/components/data-table/common/table-skeleton.tsx b/apps/dashboard/src/components/data-table/common/table-skeleton.tsx new file mode 100644 index 000000000..42149c553 --- /dev/null +++ b/apps/dashboard/src/components/data-table/common/table-skeleton.tsx @@ -0,0 +1,69 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + DEFAULT_ROW_HEIGHT_PX, + combineClassNames, + getColumnStyles, + getRowHeightStyle, + type ColumnLayout, +} from "./table"; + +type TableSkeletonProps = { + columnOrder: readonly TColumnKey[], + columnLayout: Partial>, + headerLabels: Partial>, + rowCount: number, + renderCellSkeleton: (columnKey: TColumnKey, rowIndex: number) => ReactNode, + rowHeightPx?: number, +}; + +export function TableSkeleton(props: TableSkeletonProps) { + const { columnOrder, columnLayout, headerLabels, rowCount, renderCellSkeleton, rowHeightPx } = props; + const rows = Array.from({ length: rowCount }); + const rowStyle = getRowHeightStyle(rowHeightPx ?? DEFAULT_ROW_HEIGHT_PX); + + return ( +
+
+ + + + {columnOrder.map((columnKey) => { + const layout = columnLayout[columnKey]; + return ( + + ); + })} + + + + {rows.map((_, rowIndex) => ( + + {columnOrder.map((columnKey) => { + const layout = columnLayout[columnKey]; + return ( + + ); + })} + + ))} + +
+ {headerLabels[columnKey] ?? null} +
+ {renderCellSkeleton(columnKey, rowIndex)} +
+
+
+ ); +} + diff --git a/apps/dashboard/src/components/data-table/common/table.tsx b/apps/dashboard/src/components/data-table/common/table.tsx new file mode 100644 index 000000000..ed179fb00 --- /dev/null +++ b/apps/dashboard/src/components/data-table/common/table.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { flexRender, type Table } from "@tanstack/react-table"; +import type { CSSProperties } from "react"; + +export type ColumnLayoutEntry = { + size: number, + minWidth: number, + maxWidth: number, + width: string, + headerClassName?: string, + cellClassName?: string, +}; + +export type ColumnLayout = Record; + +export type ColumnMeta = { + columnKey: TColumnKey, +}; + +export const DEFAULT_ROW_HEIGHT_PX = 50; + +export function getRowHeightStyle(heightPx: number = DEFAULT_ROW_HEIGHT_PX) { + return { height: heightPx } satisfies CSSProperties; +} + +export function combineClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(" "); +} + +export function getColumnStyles(layout?: ColumnLayoutEntry) { + if (!layout) { + return undefined; + } + return { + width: layout.width, + minWidth: layout.minWidth, + maxWidth: layout.maxWidth, + } satisfies CSSProperties; +} + +type TableContentProps = { + table: Table, + columnLayout: Partial>, + renderEmptyState: () => React.ReactNode, + rowHeightPx?: number, +}; + +export function TableContent(props: TableContentProps) { + const { + table, + columnLayout, + renderEmptyState, + rowHeightPx, + } = props; + + const resolveColumnKey = ((meta: unknown) => (meta as ColumnMeta | undefined)?.columnKey); + const rowHeightStyle = getRowHeightStyle(rowHeightPx ?? DEFAULT_ROW_HEIGHT_PX); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnKey = resolveColumnKey(header.column.columnDef.meta); + const layout = columnKey ? columnLayout[columnKey] : undefined; + return ( + + ); + })} + + ))} + + + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const columnKey = resolveColumnKey(cell.column.columnDef.meta); + const layout = columnKey ? columnLayout[columnKey] : undefined; + return ( + + ); + })} + + )) + ) : ( + + + + )} + +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {renderEmptyState()} +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/common/url-query-state.tsx b/apps/dashboard/src/components/data-table/common/url-query-state.tsx new file mode 100644 index 000000000..80a125e52 --- /dev/null +++ b/apps/dashboard/src/components/data-table/common/url-query-state.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useRouter } from "@/components/router"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { AnyObjectSchema } from "yup"; + +type Updater = Partial | ((prev: TState) => Partial); + +export type UseUrlQueryStateOptions = { + schema: AnyObjectSchema, + defaultState: TState, + sanitize?: (state: Partial) => TState, + serialize?: (state: TState) => URLSearchParams, + isEqual?: (a: TState, b: TState) => boolean, +}; + +type UseUrlQueryStateResult = { + state: TState, + setState: (updater: Updater) => void, +}; + +export function useUrlQueryState>(options: UseUrlQueryStateOptions): UseUrlQueryStateResult { + const { schema, defaultState, sanitize, serialize, isEqual } = options; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const searchParamsKey = searchParams.toString(); + + const parsedState = useMemo(() => { + const raw: Record = {}; + searchParams.forEach((value, key) => { + raw[key] = value; + }); + + let partial: Partial = {}; + try { + const result = schema.validateSync(raw, { abortEarly: false, stripUnknown: true }) as Partial; + partial = result; + } catch { + partial = {}; + } + + const sanitized = sanitize ? sanitize(partial) : { ...defaultState, ...partial }; + return sanitized as TState; + }, [schema, sanitize, defaultState, searchParams]); + + const stateRef = useRef(parsedState); + useEffect(() => { + stateRef.current = parsedState; + }, [parsedState]); + + const replaceRef = useRef(router.replace); + useEffect(() => { + replaceRef.current = router.replace; + }, [router.replace]); + + const defaultSerialize = useCallback( + (state: TState) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(state)) { + const defaultValue = (defaultState as Record)[key]; + if (value === undefined || value === null || value === defaultValue) { + continue; + } + params.set(key, String(value)); + } + return params; + }, + [defaultState], + ); + + + const setState = useCallback( + (updater: Updater) => { + const equalityFn = isEqual ?? ((a, b) => JSON.stringify(a) === JSON.stringify(b)); + const current = stateRef.current; + const patch = typeof updater === "function" ? updater(current) : updater; + const merged = { ...current, ...patch }; + const next = sanitize ? sanitize(merged) : ({ ...defaultState, ...merged } as TState); + if (equalityFn(current, next)) { + return; + } + const params = (serialize ?? defaultSerialize)(next); + const queryString = params.toString(); + const replace = replaceRef.current; + replace(queryString.length > 0 ? `${pathname}?${queryString}` : pathname); + }, + [pathname, sanitize, serialize, defaultSerialize, defaultState, isEqual], + ); + + return { state: parsedState, setState }; +} diff --git a/apps/dashboard/src/components/data-table/team-member-search-table.tsx b/apps/dashboard/src/components/data-table/team-member-search-table.tsx index a49c00094..e7235a406 100644 --- a/apps/dashboard/src/components/data-table/team-member-search-table.tsx +++ b/apps/dashboard/src/components/data-table/team-member-search-table.tsx @@ -4,7 +4,7 @@ import { ServerUser } from '@stackframe/stack'; import { AvatarCell, DataTableColumnHeader, DataTableManualPagination, SearchToolbarItem, TextCell } from "@stackframe/stack-ui"; import { ColumnDef, ColumnFiltersState, SortingState } from "@tanstack/react-table"; import { useState } from "react"; -import { extendUsers } from './user-table'; +import { extendUsers } from "./user-table"; export function TeamMemberSearchTable(props: { action: (user: ServerUser) => React.ReactNode, diff --git a/apps/dashboard/src/components/data-table/team-member-table.tsx b/apps/dashboard/src/components/data-table/team-member-table.tsx index 03f872d41..eff7ac1db 100644 --- a/apps/dashboard/src/components/data-table/team-member-table.tsx +++ b/apps/dashboard/src/components/data-table/team-member-table.tsx @@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react"; import * as yup from "yup"; import { SmartFormDialog } from "../form-dialog"; import { PermissionListField } from "../permission-field"; -import { ExtendedServerUser, extendUsers, getCommonUserColumns } from "./user-table"; +import { extendUsers, getCommonUserColumns, type ExtendedServerUser } from "./user-table"; type ExtendedServerUserForTeam = ExtendedServerUser & { diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 0400c5ac8..fe029f37f 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -1,241 +1,925 @@ -'use client'; -import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app'; +"use client"; + +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { useRouter } from "@/components/router"; -import { ServerUser } from '@stackframe/stack'; -import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects'; -import { deindent } from '@stackframe/stack-shared/dist/utils/strings'; -import { ActionCell, AvatarCell, BadgeCell, DataTableColumnHeader, DataTableManualPagination, DateCell, SearchToolbarItem, SimpleTooltip, TextCell } from "@stackframe/stack-ui"; -import { ColumnDef, ColumnFiltersState, Row, SortingState, Table } from "@tanstack/react-table"; -import { useState } from "react"; -import { Link } from '../link'; -import { CreateCheckoutDialog } from '../payments/create-checkout-dialog'; -import { DeleteUserDialog, ImpersonateUserDialog } from '../user-dialogs'; +import type { ServerUser } from "@stackframe/stack"; +import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; +import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SimpleTooltip, + Skeleton, + toast, +} from "@stackframe/stack-ui"; +import { + ColumnDef, + createColumnHelper, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ArrowDown, ArrowUp, CheckCircle2, ChevronLeft, ChevronRight, Copy, MoreHorizontal, Search, X, XCircle } from "lucide-react"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import * as yup from "yup"; +import { Link } from "../link"; +import { CreateCheckoutDialog } from "../payments/create-checkout-dialog"; +import { DeleteUserDialog, ImpersonateUserDialog } from "../user-dialogs"; +import { useCursorPaginationCache } from "./common/cursor-pagination"; +import { PaginationControls } from "./common/pagination"; +import { useStableValue } from "./common/stable-value"; +import { + TableContent, + type ColumnLayout, + type ColumnMeta, +} from "./common/table"; +import { TableSkeleton } from "./common/table-skeleton"; +import { useUrlQueryState } from "./common/url-query-state"; + +type QueryState = { + search?: string, + includeAnonymous: boolean, + page: number, + pageSize: number, + cursor?: string, + signedUpOrder: "asc" | "desc", +}; + +type QueryUpdater = + | Partial + | ((prev: QueryState) => Partial); + +const DEFAULT_PAGE_SIZE = 10; +const PAGE_SIZE_OPTIONS = [10, 25, 50]; +const SEARCH_DEBOUNCE_MS = 250; export type ExtendedServerUser = ServerUser & { authTypes: string[], - emailVerified: 'verified' | 'unverified', + emailVerified: "verified" | "unverified", }; -function userToolbarRender(table: Table, showAnonymous: boolean, setShowAnonymous: (value: boolean) => void) { - return ( - <> - -
- -
- - ); -} +const AUTH_TYPE_LABELS = new Map([ + ["anonymous", "Anonymous"], + ["otp", "Authenticator"], + ["password", "Password"], +]); -function UserActions({ row }: { row: Row }) { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [impersonateSnippet, setImpersonateSnippet] = useState(null); - const [isCreateCheckoutModalOpen, setIsCreateCheckoutModalOpen] = useState(false); - const app = useAdminApp(); - const router = useRouter(); - return ( - <> - - setImpersonateSnippet(null)} /> - - { - router.push(`/projects/${encodeURIComponent(app.projectId)}/users/${encodeURIComponent(row.original.id)}`); - }, - }, - { - item: "Impersonate", - onClick: async () => { - const expiresInMillis = 1000 * 60 * 60 * 2; - const expiresAtDate = new Date(Date.now() + expiresInMillis); - const session = await row.original.createSession({ expiresInMillis, isImpersonation: true }); - const tokens = await session.getTokens(); - setImpersonateSnippet(deindent` - document.cookie = 'stack-refresh-${app.projectId}=${tokens.refreshToken}; expires=${expiresAtDate.toUTCString()}; path=/'; - window.location.reload(); - `); - } - }, - { - item: "Create Checkout", - onClick: () => setIsCreateCheckoutModalOpen(true), - }, - ...row.original.isMultiFactorRequired ? [{ - item: "Remove 2FA", - onClick: async () => { - await row.original.update({ totpMultiFactorSecret: null }); - }, - }] : [], - '-', - { - item: "Delete", - onClick: () => setIsDeleteModalOpen(true), - danger: true, - }, - ]} - /> - - ); -} +type ColumnKey = + | "user" + | "email" + | "userId" + | "emailStatus" + | "lastActiveAt" + | "auth" + | "signedUpAt" + | "actions"; -function AvatarCellWrapper({ user }: { user: ServerUser }) { - const stackAdminApp = useAdminApp(); - return - - ; -} +type ColumnLayoutMap = ColumnLayout; +type ColumnMetaType = ColumnMeta; -export const getCommonUserColumns = () => [ - { - accessorKey: "profileImageUrl", - header: ({ column }) => , - cell: ({ row }) => { - return ; - }, - enableSorting: false, - }, - { - accessorKey: "id", - header: ({ column }) => , - cell: ({ row }) => {row.original.id}, - enableSorting: false, - }, - { - accessorKey: "displayName", - header: ({ column }) => , - cell: ({ row }) => -
- {row.original.displayName ?? '–'} - {row.original.isAnonymous && Anonymous} -
-
, - enableSorting: false, - }, - { - accessorKey: "primaryEmail", - header: ({ column }) => , - cell: ({ row }) => }> - {row.original.primaryEmail ?? '–'} - , - enableSorting: false, - }, - { - accessorKey: "lastActiveAt", - header: ({ column }) => , - cell: ({ row }) => , - enableSorting: false, - }, - { - accessorKey: "emailVerified", - header: ({ column }) => , - cell: ({ row }) => {row.original.emailVerified === 'verified' ? '✓' : '✗'}, - enableSorting: false, - }, -] satisfies ColumnDef[]; -const columns: ColumnDef[] = [ - ...getCommonUserColumns(), - { - accessorKey: "authTypes", - header: ({ column }) => , - cell: ({ row }) => , - enableSorting: false, +const COLUMN_LAYOUT: ColumnLayoutMap = { + user: { size: 160, minWidth: 110, maxWidth: 160, width: "clamp(110px, 22vw, 160px)" }, + email: { size: 160, minWidth: 110, maxWidth: 160, width: "clamp(110px, 22vw, 160px)" }, + userId: { size: 130, minWidth: 90, maxWidth: 130, width: "clamp(90px, 18vw, 130px)" }, + emailStatus: { size: 110, minWidth: 80, maxWidth: 110, width: "clamp(80px, 16vw, 110px)" }, + lastActiveAt: { size: 110, minWidth: 80, maxWidth: 110, width: "clamp(80px, 16vw, 110px)" }, + auth: { size: 150, minWidth: 110, maxWidth: 150, width: "clamp(110px, 20vw, 150px)" }, + signedUpAt: { size: 110, minWidth: 80, maxWidth: 110, width: "clamp(80px, 16vw, 110px)" }, + actions: { + size: 40, + minWidth: 40, + maxWidth: 40, + width: "clamp(40px, 10vw, 40px)", + headerClassName: "text-right", + cellClassName: "text-right", }, - { - accessorKey: "signedUpAt", - header: ({ column }) => , - cell: ({ row }) => , - }, - { - id: "actions", - cell: ({ row }) => , - }, -]; +}; -export function extendUsers(users: ServerUser[] & { nextCursor: string | null }): ExtendedServerUser[] & { nextCursor: string | null }; -export function extendUsers(users: ServerUser[]): ExtendedServerUser[]; -export function extendUsers(users: ServerUser[] & { nextCursor?: string | null }): ExtendedServerUser[] & { nextCursor: string | null | undefined } { - const extended = users.map((user) => ({ - ...user, - authTypes: user.isAnonymous ? ["anonymous"] : [ - ...user.otpAuthEnabled ? ["otp"] : [], - ...user.hasPassword ? ["password"] : [], - ...user.oauthProviders.map(p => p.id), - ], - emailVerified: user.primaryEmailVerified ? "verified" : "unverified", - } satisfies ExtendedServerUser)).sort((a, b) => a.signedUpAt > b.signedUpAt ? -1 : 1); - return Object.assign(extended, { nextCursor: users.nextCursor }); -} +const DEFAULT_QUERY_STATE: QueryState = { + includeAnonymous: false, + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + signedUpOrder: "desc", +}; + +const numberTransform = (_value: unknown, originalValue: unknown) => { + if (typeof originalValue === "number" && Number.isFinite(originalValue)) { + return originalValue; + } + if (typeof originalValue === "string") { + const trimmed = originalValue.trim(); + if (trimmed.length === 0) { + return undefined; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +const optionalStringTransform = (value: unknown) => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const querySchema = yup.object({ + search: yup + .string() + .transform((_, originalValue) => optionalStringTransform(originalValue)) + .optional(), + includeAnonymous: yup + .boolean() + .transform((_, originalValue) => (originalValue === "true" ? true : undefined)) + .optional(), + page: yup + .number() + .transform(numberTransform) + .integer() + .positive() + .optional(), + pageSize: yup + .number() + .transform(numberTransform) + .integer() + .positive() + .optional(), + cursor: yup + .string() + .transform((_, originalValue) => optionalStringTransform(originalValue)) + .optional(), + signedUpOrder: yup + .mixed<"asc" | "desc">() + .transform((_, originalValue) => (originalValue === "asc" || originalValue === "desc" ? originalValue : undefined)) + .optional(), +}); + +const columnHelper = createColumnHelper(); export function UserTable() { + const { query, setQuery } = useUserTableQueryState(); + const [searchInput, setSearchInput] = useState(query.search ?? ""); + const cursorPaginationCache = useCursorPaginationCache(); + + useEffect(() => { + setSearchInput(query.search ?? ""); + }, [query.search]); + + useEffect(() => { + const trimmed = searchInput.trim(); + const normalized = trimmed.length === 0 ? undefined : trimmed; + if (normalized === (query.search ?? undefined)) { + return; + } + const handle = setTimeout(() => { + setQuery((prev) => ({ + ...prev, + page: 1, + cursor: undefined, + search: normalized, + })); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [searchInput, query.search, setQuery]); + + useEffect(() => { + cursorPaginationCache.resetCache(); + }, [cursorPaginationCache, query.search, query.includeAnonymous, query.pageSize, query.signedUpOrder]); + + useEffect(() => { + if (query.page > 1 && !query.cursor) { + setQuery((prev) => ({ ...prev, page: 1, cursor: undefined })); + } + }, [query.page, query.cursor, setQuery]); + + return ( +
+ + setQuery((prev) => ({ ...prev, includeAnonymous: value, page: 1, cursor: undefined })) + } + /> +
+ }> + + +
+
+ ); +} + +function UserTableHeader(props: { + searchValue: string, + onSearchChange: (value: string) => void, + includeAnonymous: boolean, + onIncludeAnonymousChange: (value: boolean) => void, +}) { + const { searchValue, onSearchChange, includeAnonymous, onIncludeAnonymousChange } = props; + + return ( +
+
+
+ onSearchChange(event.target.value)} + placeholder="Search table" + className="!px-8" + autoComplete="off" + /> + + {searchValue.length > 0 && ( + + )} +
+
+ +
+
+
+ ); +} + +function UserTableBody(props: { + query: QueryState, + setQuery: (updater: QueryUpdater) => void, + cursorPaginationCache: ReturnType, +}) { const stackAdminApp = useAdminApp(); - const router = useRouter(); - const [filters, setFilters] = useState[0]>({ - limit: 10, - orderBy: "signedUpAt", - desc: true, - includeAnonymous: false, + const { query, setQuery } = props; + const { + readCursorForPage, + recordPageCursor, + recordNextCursor, + prefetchCursor, + resetCache, + } = props.cursorPaginationCache; + + const baseOptions = useMemo( + () => ({ + limit: query.pageSize, + orderBy: "signedUpAt" as const, + desc: query.signedUpOrder === "desc", + query: query.search, + includeAnonymous: query.includeAnonymous, + }), + [query.pageSize, query.search, query.includeAnonymous, query.signedUpOrder], + ); + + const storedCursor = readCursorForPage(query.page); + const cursorToUse = useMemo(() => { + if (query.page === 1) { + return undefined; + } + if (storedCursor && storedCursor.length > 0) { + return storedCursor; + } + return storedCursor === null ? undefined : query.cursor; + }, [query.page, query.cursor, storedCursor]); + + const listOptions = useMemo( + () => ({ + ...baseOptions, + cursor: cursorToUse, + }), + [baseOptions, cursorToUse], + ); + + const rawUsers = stackAdminApp.useUsers(listOptions); + const usersFingerprint = useMemo(() => getUsersFingerprint(rawUsers), [rawUsers]); + const stableRawUsers = useStableValue(rawUsers, usersFingerprint); + const users = useMemo(() => extendUsers(stableRawUsers), [stableRawUsers]); + + useEffect(() => { + recordPageCursor(query.page, query.page === 1 ? null : cursorToUse ?? null); + }, [query.page, cursorToUse, recordPageCursor]); + + useEffect(() => { + recordNextCursor(query.page, users.nextCursor); + }, [query.page, users.nextCursor, recordNextCursor]); + + useEffect(() => { + prefetchCursor(users.nextCursor, () => + runAsynchronously( + stackAdminApp.listUsers({ + ...baseOptions, + cursor: users.nextCursor ?? undefined, + }), + ), + ); + }, [users.nextCursor, stackAdminApp, baseOptions, prefetchCursor]); + + const columns = useMemo[]>( + () => createUserColumns(setQuery, query.signedUpOrder === "desc"), + [setQuery, query.signedUpOrder], + ); + + const table = useReactTable({ + data: users, + columns, + getCoreRowModel: getCoreRowModel(), }); - const users = extendUsers(stackAdminApp.useUsers(filters)); + const hasNextPage = users.nextCursor !== null; + const hasPreviousPage = query.page > 1; - const onUpdate = async (options: { - cursor: string, - limit: number, - sorting: SortingState, - columnFilters: ColumnFiltersState, - globalFilters: any, - }) => { - let newFilters: Parameters[0] = { - cursor: options.cursor, - limit: options.limit, - query: options.globalFilters, - }; + return ( +
+ ( +
+
+ +
+
No users found
+

+ Try adjusting your search or filters +

+ +
+ )} + /> + + setQuery((prev) => ({ ...prev, pageSize: value, page: 1, cursor: undefined })) + } + onPreviousPage={() => { + if (!hasPreviousPage) { + return; + } + const previousPage = query.page - 1; + const previousCursor = readCursorForPage(previousPage); + setQuery({ page: previousPage, cursor: previousPage === 1 ? undefined : previousCursor ?? undefined }); + }} + onNextPage={() => { + if (!hasNextPage) { + return; + } + setQuery({ page: query.page + 1, cursor: users.nextCursor ?? undefined }); + }} + /> +
+ ); +} - const orderMap = { - signedUpAt: "signedUpAt", - } as const; - if (options.sorting.length > 0 && options.sorting[0].id in orderMap) { - newFilters.orderBy = orderMap[options.sorting[0].id as keyof typeof orderMap]; - newFilters.desc = options.sorting[0].desc; - } - - if (deepPlainEquals(newFilters, filters, { ignoreUndefinedValues: true })) { - // save ourselves a request if the filters didn't change - return { nextCursor: users.nextCursor }; - } else { - setFilters(newFilters); - const users = await stackAdminApp.listUsers(newFilters); - return { nextCursor: users.nextCursor }; +function UserTableSkeleton(props: { pageSize: number }) { + const { pageSize } = props; + const columnOrder: ColumnKey[] = [ + "user", + "email", + "userId", + "emailStatus", + "lastActiveAt", + "auth", + "signedUpAt", + "actions", + ]; + const skeletonHeaders: Record = { + user: "User", + email: "Email", + userId: "User ID", + emailStatus: "Email Verified", + lastActiveAt: "Last active", + auth: "Auth methods", + signedUpAt: "Signed up", + actions: null, + }; + const renderSkeletonCellContent = (columnKey: ColumnKey): JSX.Element => { + switch (columnKey) { + case "user": { + return ( +
+ +
+ +
+
+ ); + } + case "email": { + return ; + } + case "userId": { + return ; + } + case "emailStatus": { + return ; + } + case "lastActiveAt": { + return ; + } + case "auth": { + return ( +
+ +
+ ); + } + case "signedUpAt": { + return ; + } + case "actions": { + return ; + } + default: { + throw new Error("Unhandled skeleton column"); + } } }; - return userToolbarRender(table, filters?.includeAnonymous ?? false, (value) => setFilters(prev => ({ ...prev, includeAnonymous: value })))} - onUpdate={onUpdate} - defaultVisibility={{ emailVerified: false }} - defaultColumnFilters={[]} - defaultSorting={[{ id: 'signedUpAt', desc: true }]} - onRowClick={(row) => { - router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(row.id)}`); - }} - />; + return ( +
+ renderSkeletonCellContent(columnKey)} + /> +
+
+ Rows per page + +
+
+ + + Page … + + +
+
+
+ ); +} + +function createUserColumns( + setQuery: (updater: QueryUpdater) => void, + isSignedUpDesc: boolean, +): ColumnDef[] { + const toggleSignedUpOrder = () => + setQuery((prev) => ({ + signedUpOrder: prev.signedUpOrder === "desc" ? "asc" : "desc", + page: 1, + cursor: undefined, + })); + + return [ + ...getCommonUserColumns(), + columnHelper.display({ + id: "auth", + size: COLUMN_LAYOUT.auth.size, + minSize: COLUMN_LAYOUT.auth.minWidth, + maxSize: COLUMN_LAYOUT.auth.maxWidth, + meta: { columnKey: "auth" } as ColumnMetaType, + header: () => Auth methods, + cell: ({ row }) => , + }), + columnHelper.display({ + id: "signedUpAt", + size: COLUMN_LAYOUT.signedUpAt.size, + minSize: COLUMN_LAYOUT.signedUpAt.minWidth, + maxSize: COLUMN_LAYOUT.signedUpAt.maxWidth, + meta: { columnKey: "signedUpAt" } as ColumnMetaType, + header: () => ( + + ), + cell: ({ row }) => , + }), + columnHelper.display({ + id: "actions", + size: COLUMN_LAYOUT.actions.size, + minSize: COLUMN_LAYOUT.actions.minWidth, + maxSize: COLUMN_LAYOUT.actions.maxWidth, + meta: { columnKey: "actions" } as ColumnMetaType, + header: () => Actions, + cell: ({ row }) => , + }), + ]; +} + +function UserActions(props: { user: ExtendedServerUser }) { + const { user } = props; + const stackAdminApp = useAdminApp(); + const router = useRouter(); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); + const [impersonateSnippet, setImpersonateSnippet] = useState(null); + + return ( +
+ + setImpersonateSnippet(null)} /> + + + + + + + + router.push(`/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`) + } + > + View details + + + runAsynchronouslyWithAlert(async () => { + const expiresInMillis = 1000 * 60 * 60 * 2; + const expiresAtDate = new Date(Date.now() + expiresInMillis); + const session = await user.createSession({ expiresInMillis, isImpersonation: true }); + const tokens = await session.getTokens(); + setImpersonateSnippet( + deindent` + document.cookie = 'stack-refresh-${stackAdminApp.projectId}=${tokens.refreshToken}; expires=${expiresAtDate.toUTCString()}; path=/'; + window.location.reload(); + `, + ); + }) + } + > + Impersonate + + setIsCheckoutOpen(true)}>Create checkout + {user.isMultiFactorRequired && ( + + runAsynchronouslyWithAlert(async () => { + await user.update({ totpMultiFactorSecret: null }); + }) + } + > + Remove 2FA + + )} + + setIsDeleteOpen(true)} className="text-destructive focus:text-destructive"> + Delete user + + + +
+ ); +} + +function getUsersFingerprint(users: ServerUser[] & { nextCursor: string | null }) { + const userSegments = users + .map((user) => [ + user.id, + user.displayName ?? "", + user.primaryEmail ?? "", + user.primaryEmailVerified ? "1" : "0", + user.isAnonymous ? "1" : "0", + normalizeDateValue(user.lastActiveAt), + normalizeDateValue(user.signedUpAt), + user.otpAuthEnabled ? "1" : "0", + user.hasPassword ? "1" : "0", + user.profileImageUrl ?? "", + user.isMultiFactorRequired ? "1" : "0", + user.oauthProviders.map((provider) => provider.id).sort().join(","), + ].join("~")) + .join("||"); + return `${users.nextCursor ?? ""}::${userSegments}`; +} + +function normalizeDateValue(value: Date | string | null | undefined) { + if (!value) { + return ""; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return String(date.getTime()); +} + +function sanitizeQueryState(state: Partial): QueryState { + const search = state.search?.trim() ? state.search.trim() : undefined; + const includeAnonymous = Boolean(state.includeAnonymous); + const candidatePageSize = state.pageSize ?? DEFAULT_PAGE_SIZE; + const pageSize = PAGE_SIZE_OPTIONS.includes(candidatePageSize) ? candidatePageSize : DEFAULT_PAGE_SIZE; + const candidatePage = state.page ?? 1; + const page = Number.isFinite(candidatePage) ? Math.max(1, Math.floor(candidatePage)) : 1; + const cursor = page > 1 && state.cursor ? state.cursor : undefined; + const signedUpOrder = state.signedUpOrder === "asc" ? "asc" : "desc"; + return { search, includeAnonymous, page, pageSize, cursor, signedUpOrder }; +} + +function serializeQueryState(state: QueryState) { + const params = new URLSearchParams(); + if (state.search) { + params.set("search", state.search); + } + if (state.includeAnonymous) { + params.set("includeAnonymous", "true"); + } + if (state.page > 1) { + params.set("page", String(state.page)); + } + if (state.pageSize !== DEFAULT_PAGE_SIZE) { + params.set("pageSize", String(state.pageSize)); + } + if (state.signedUpOrder !== "desc") { + params.set("signedUpOrder", state.signedUpOrder); + } + if (state.cursor) { + params.set("cursor", state.cursor); + } + return params; +} + +function useUserTableQueryState() { + const { state, setState } = useUrlQueryState({ + schema: querySchema, + defaultState: DEFAULT_QUERY_STATE, + sanitize: sanitizeQueryState, + serialize: serializeQueryState, + }); + return { query: state, setQuery: setState }; +} + +function titleCase(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function formatUserId(id: string) { + if (id.length <= 10) { + return id; + } + return `${id.slice(0, 6)}…${id.slice(-4)}`; +} + +export function extendUsers(users: ServerUser[] & { nextCursor: string | null }): ExtendedServerUser[] & { nextCursor: string | null }; +export function extendUsers(users: ServerUser[]): ExtendedServerUser[]; +export function extendUsers(users: ServerUser[] & { nextCursor?: string | null }) { + const extended = users.map((user) => { + const authTypes = user.isAnonymous + ? ["anonymous"] + : [ + ...(user.otpAuthEnabled ? ["otp"] : []), + ...(user.hasPassword ? ["password"] : []), + ...user.oauthProviders.map((provider) => provider.id), + ]; + return { + ...user, + authTypes, + emailVerified: user.primaryEmailVerified ? "verified" : "unverified", + } satisfies ExtendedServerUser; + }); + return Object.assign(extended, { nextCursor: users.nextCursor ?? null }); +} + +export function getCommonUserColumns(): ColumnDef[] { + const helper = createColumnHelper(); + return [ + helper.display({ + id: "user", + size: COLUMN_LAYOUT.user.size, + minSize: COLUMN_LAYOUT.user.minWidth, + maxSize: COLUMN_LAYOUT.user.maxWidth, + meta: { columnKey: "user" } as ColumnMetaType, + header: () => User, + cell: ({ row }) => , + }), + helper.display({ + id: "email", + size: COLUMN_LAYOUT.email.size, + minSize: COLUMN_LAYOUT.email.minWidth, + maxSize: COLUMN_LAYOUT.email.maxWidth, + meta: { columnKey: "email" } as ColumnMetaType, + header: () => Email, + cell: ({ row }) => , + }), + helper.display({ + id: "userId", + size: COLUMN_LAYOUT.userId.size, + minSize: COLUMN_LAYOUT.userId.minWidth, + maxSize: COLUMN_LAYOUT.userId.maxWidth, + meta: { columnKey: "userId" } as ColumnMetaType, + header: () => User ID, + cell: ({ row }) => , + }), + helper.display({ + id: "emailStatus", + size: COLUMN_LAYOUT.emailStatus.size, + minSize: COLUMN_LAYOUT.emailStatus.minWidth, + maxSize: COLUMN_LAYOUT.emailStatus.maxWidth, + meta: { columnKey: "emailStatus" } as ColumnMetaType, + header: () => Email Verified, + cell: ({ row }) => , + }), + helper.display({ + id: "lastActiveAt", + size: COLUMN_LAYOUT.lastActiveAt.size, + minSize: COLUMN_LAYOUT.lastActiveAt.minWidth, + maxSize: COLUMN_LAYOUT.lastActiveAt.maxWidth, + meta: { columnKey: "lastActiveAt" } as ColumnMetaType, + header: () => Last active, + cell: ({ row }) => , + }), + ]; +} + +function UserIdentityCell(props: { user: ExtendedServerUser }) { + const { user } = props; + const stackAdminApp = useAdminApp(); + const profileUrl = `/projects/${encodeURIComponent(stackAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`; + const fallback = user.displayName?.charAt(0) ?? user.primaryEmail?.charAt(0) ?? "?"; + const displayName = user.displayName ?? user.primaryEmail ?? "Unnamed user"; + + return ( +
+ + + + {fallback} + + +
+
+ + + {displayName} + + + {user.isAnonymous && ( + + Anonymous + + )} +
+
+
+ ); +} + +function UserIdCell(props: { user: ExtendedServerUser }) { + const { user } = props; + const idLabel = formatUserId(user.id); + + const handleCopy = async () => { + await navigator.clipboard.writeText(user.id); + toast({ title: "Copied to clipboard", variant: "success" }); + }; + + return ( + + + + ); +} + +function UserEmailCell(props: { user: ExtendedServerUser }) { + const { user } = props; + const email = user.primaryEmail ?? "No email"; + + return ( + + {email} + + ); +} + +function EmailStatusCell(props: { user: ExtendedServerUser }) { + const { user } = props; + const isVerified = user.emailVerified === "verified"; + return ( +
+ {isVerified ? ( + + ) : ( + + )} +
+ ); +} + +function AuthMethodsCell(props: { user: ExtendedServerUser }) { + const { user } = props; + const authLabels = user.authTypes.length > 0 ? user.authTypes : ["none"]; + + return ( +
+ {authLabels.map((type) => { + const label = type === "none" ? "None" : AUTH_TYPE_LABELS.get(type) ?? titleCase(type); + return ( + + {label} + + ); + })} +
+ ); +} + +function DateMetaCell(props: { value: Date | string | null | undefined, emptyLabel: string }) { + const { value, emptyLabel } = props; + const meta = getDateMeta(value, emptyLabel); + return ( + + {meta.label} + + ); +} + +function getDateMeta(value: Date | string | null | undefined, emptyLabel: string) { + if (!value) { + return { label: emptyLabel }; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return { label: emptyLabel }; + } + return { + label: fromNow(date), + tooltip: date.toString(), + }; }