From d0b1235e44d5c5746ab671294eb98b1e28e9cd54 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Fri, 21 Nov 2025 02:49:54 +0000 Subject: [PATCH] Implemented export users functionality --- apps/dashboard/package.json | 1 + .../[projectId]/users/page-client.tsx | 33 +- .../src/components/data-table/user-table.tsx | 13 +- .../src/components/export-users-dialog.tsx | 389 ++++++++++++++++++ pnpm-lock.yaml | 5 +- 5 files changed, 432 insertions(+), 9 deletions(-) create mode 100644 apps/dashboard/src/components/export-users-dialog.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index ee687b5f90..de3a9a570e 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -42,6 +42,7 @@ "canvas-confetti": "^1.9.2", "clsx": "^2.0.0", "dotenv-cli": "^7.3.0", + "export-to-csv": "^1.4.0", "geist": "^1", "jose": "^5.2.2", "lodash": "^4.17.21", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx index b3dbc9da98..a54c319aae 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx @@ -2,10 +2,12 @@ import { stackAppInternalsSymbol } from "@/app/(main)/integrations/transfer-confirm-page"; import { UserTable } from "@/components/data-table/user-table"; +import { ExportUsersDialog } from "@/components/export-users-dialog"; import { StyledLink } from "@/components/link"; import { UserDialog } from "@/components/user-dialog"; import { Alert, Button, Skeleton } from "@stackframe/stack-ui"; -import { Suspense } from "react"; +import { Download } from "lucide-react"; +import { Suspense, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; @@ -31,7 +33,11 @@ function TotalUsersDisplay() { export default function PageClient() { const stackAdminApp = useAdminApp(); - const firstUser = stackAdminApp.useUsers({ limit: 1 }); + const firstUser = (stackAdminApp as any).useUsers({ limit: 1 }); + const [exportOptions, setExportOptions] = useState<{ + search?: string, + includeAnonymous: boolean, + }>({ includeAnonymous: false }); return ( @@ -43,10 +49,23 @@ export default function PageClient() { } - actions={Create User} - />} + actions={ +
+ + + Export + + } + exportOptions={exportOptions} + /> + Create User} + /> +
+ } > {firstUser.length > 0 ? null : ( @@ -54,7 +73,7 @@ export default function PageClient() { )} - +
); diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index fe029f37fe..4bf2801ac3 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -173,7 +173,9 @@ const querySchema = yup.object({ const columnHelper = createColumnHelper(); -export function UserTable() { +export function UserTable(props?: { + onFilterChange?: (filters: { search?: string, includeAnonymous: boolean }) => void, +}) { const { query, setQuery } = useUserTableQueryState(); const [searchInput, setSearchInput] = useState(query.search ?? ""); const cursorPaginationCache = useCursorPaginationCache(); @@ -209,6 +211,15 @@ export function UserTable() { } }, [query.page, query.cursor, setQuery]); + const onFilterChange = props?.onFilterChange; + + useEffect(() => { + onFilterChange?.({ + search: query.search, + includeAnonymous: query.includeAnonymous, + }); + }, [query.search, query.includeAnonymous, onFilterChange]); + return (
("csv"); + const [scope, setScope] = useState("all"); + const [fields, setFields] = useState(DEFAULT_FIELDS); + const [isExporting, setIsExporting] = useState(false); + + const toggleField = (key: string) => { + setFields((prev) => + prev.map((field) => + field.key === key ? { ...field, enabled: !field.enabled } : field + ) + ); + }; + + const selectAllFields = () => { + setFields((prev) => prev.map((field) => ({ ...field, enabled: true }))); + }; + + const deselectAllFields = () => { + setFields((prev) => prev.map((field) => ({ ...field, enabled: false }))); + }; + + const handleExport = async () => { + const enabledFields = fields.filter((f) => f.enabled); + if (enabledFields.length === 0) { + toast({ + title: "No fields selected", + description: "Please select at least one field to export", + variant: "destructive", + }); + return; + } + + setIsExporting(true); + try { + // Fetch all users + const allUsers = await fetchAllUsers( + stackAdminApp, + scope === "filtered" ? exportOptions : undefined + ); + + if (allUsers.length === 0) { + toast({ + title: "No users to export", + description: "There are no users matching the current filters", + variant: "destructive", + }); + setIsExporting(false); + return; + } + + // Transform user data based on selected fields + const transformedData = allUsers.map((user) => + transformUserData(user, enabledFields) + ); + + // Export based on format + if (format === "csv") { + exportToCsv(transformedData); + } else { + exportToJson(transformedData); + } + + toast({ + title: "Export successful", + description: `Exported ${allUsers.length} user${allUsers.length === 1 ? "" : "s"}`, + variant: "success", + }); + + setOpen(false); + } catch (error) { + console.error("Export failed:", error); + toast({ + title: "Export failed", + description: error instanceof Error ? error.message : "An unknown error occurred", + variant: "destructive", + }); + } finally { + setIsExporting(false); + } + }; + + return ( + <> +
setOpen(true)}> + {trigger} +
+ + + + Export Users + + Configure and download user data from your project + + + +
+ {/* Export Format */} +
+ + +
+ + {/* Export Scope */} +
+ + setScope(v as ExportScope)}> +
+ + +
+
+ + +
+
+
+ + {/* Field Selection */} +
+
+ +
+ + +
+
+
+ {fields.map((field) => ( +
+ toggleField(field.key)} + /> + +
+ ))} +
+
+ + {/* Export Button */} +
+ + +
+
+
+
+ + ); +} + +async function fetchAllUsers( + stackAdminApp: ReturnType, + options?: ExportOptions +): Promise { + const allUsers: ServerUser[] = []; + let cursor: string | undefined = undefined; + const limit = 100; // Fetch in batches of 100 + + do { + const batch = await stackAdminApp.listUsers({ + limit, + cursor, + query: options?.search, + includeAnonymous: options?.includeAnonymous ?? true, + orderBy: "signedUpAt", + desc: true, + }); + + allUsers.push(...batch); + cursor = batch.nextCursor ?? undefined; + } while (cursor); + + return allUsers; +} + +function transformUserData( + user: ServerUser, + enabledFields: ExportField[] +): Record { + const data: Record = {}; + + for (const field of enabledFields) { + switch (field.key) { + case "id": { + data["User ID"] = user.id; + break; + } + case "displayName": { + data["Display Name"] = user.displayName ?? ""; + break; + } + case "primaryEmail": { + data["Email"] = user.primaryEmail ?? ""; + break; + } + case "primaryEmailVerified": { + data["Email Verified"] = user.primaryEmailVerified ? "Yes" : "No"; + break; + } + case "signedUpAt": { + data["Signed Up At"] = new Date(user.signedUpAt).toISOString(); + break; + } + case "lastActiveAt": { + data["Last Active At"] = new Date(user.lastActiveAt).toISOString(); + break; + } + case "isAnonymous": { + data["Is Anonymous"] = user.isAnonymous ? "Yes" : "No"; + break; + } + case "hasPassword": { + data["Has Password"] = user.hasPassword ? "Yes" : "No"; + break; + } + case "otpAuthEnabled": { + data["OTP Auth Enabled"] = user.otpAuthEnabled ? "Yes" : "No"; + break; + } + case "passkeyAuthEnabled": { + data["Passkey Auth Enabled"] = user.passkeyAuthEnabled ? "Yes" : "No"; + break; + } + case "isMultiFactorRequired": { + data["Multi-Factor Required"] = user.isMultiFactorRequired ? "Yes" : "No"; + break; + } + case "oauthProviders": { + data["OAuth Providers"] = user.oauthProviders.map((p) => p.id).join(", "); + break; + } + case "profileImageUrl": { + data["Profile Image URL"] = user.profileImageUrl ?? ""; + break; + } + case "clientMetadata": { + data["Client Metadata"] = JSON.stringify(user.clientMetadata ?? {}); + break; + } + case "clientReadOnlyMetadata": { + data["Client Read-Only Metadata"] = JSON.stringify(user.clientReadOnlyMetadata ?? {}); + break; + } + case "serverMetadata": { + data["Server Metadata"] = JSON.stringify(user.serverMetadata ?? {}); + break; + } + } + } + + return data; +} + +function exportToCsv(data: Record[]) { + const csvConfig = mkConfig({ + fieldSeparator: ",", + filename: `stack-users-export-${new Date().toISOString().split("T")[0]}`, + decimalSeparator: ".", + useKeysAsHeaders: true, + }); + + const csv = generateCsv(csvConfig)(data as any); + download(csvConfig)(csv); +} + +function exportToJson(data: Record[]) { + const jsonString = JSON.stringify(data, null, 2); + const blob = new Blob([jsonString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `stack-users-export-${new Date().toISOString().split("T")[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 141bbff0f4..fd1b1b92ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -400,6 +400,9 @@ importers: dotenv-cli: specifier: ^7.3.0 version: 7.4.1 + export-to-csv: + specifier: ^1.4.0 + version: 1.4.0 geist: specifier: ^1 version: 1.3.0(next@16.0.0(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) @@ -33974,7 +33977,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@babel/runtime': 7.26.0 + '@babel/runtime': 7.28.4 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1