From 164374f6c834fb820adf3e7ce8f6ea5bce71df22 Mon Sep 17 00:00:00 2001 From: Vedanta-Gawande <30631624+Vedanta-Gawande@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:27:24 -0400 Subject: [PATCH] User page email filtering (#1668) --- .../backend/src/app/api/latest/users/crud.tsx | 50 ++ .../[projectId]/email-sent/page-client.tsx | 54 ++ .../[projectId]/users/page-client.tsx | 20 +- apps/dashboard/src/app/globals.css | 22 + .../src/components/data-table/team-table.tsx | 57 +++ .../data-table/transaction-table.tsx | 64 ++- .../src/components/data-table/user-table.tsx | 317 ++++++++++-- .../src/components/export-users-dialog.tsx | 394 -------------- .../backend/endpoints/api/v1/users.test.ts | 83 +++ apps/e2e/tests/js/list-users.test.ts | 43 ++ docs-mintlify/openapi/admin.json | 10 + docs-mintlify/openapi/server.json | 10 + .../data-grid/data-grid-export-dialog.tsx | 479 ++++++++++++++++++ .../src/components/data-grid/data-grid.tsx | 379 +++++++------- .../src/components/data-grid/index.ts | 5 + .../src/components/data-grid/types.ts | 35 ++ .../shared/src/interface/server-interface.ts | 4 + .../apps/implementations/server-app-impl.ts | 18 +- .../src/lib/hexclave-app/teams/index.ts | 4 + 19 files changed, 1395 insertions(+), 653 deletions(-) delete mode 100644 apps/dashboard/src/components/export-users-dialog.tsx create mode 100644 packages/dashboard-ui-components/src/components/data-grid/data-grid-export-dialog.tsx diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index a0591a97a..6e29837ff 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -61,11 +61,40 @@ const getPersonalTeamDisplayName = (userDisplayName: string | null, userPrimaryE }; const personalTeamDefaultDisplayName = "Personal Team"; +// Keep in sync with the Users table parser. This validates exact domains only; +// excluding gmail.com intentionally does not exclude mail.gmail.com. +const emailDomainRegex = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const maxExcludedEmailDomains = 100; // to prevent abuse function getSignedUpAtMillis(signedUpAt: Date): number { return signedUpAt.getTime(); } +// lowercases, strips leading @, handles duplicates to validate domains +function normalizeExcludedEmailDomains(rawDomains: string | undefined): string[] { + if (rawDomains === undefined || rawDomains.trim() === "") { + return []; + } + + const normalizedDomains = new Map(); + for (const rawDomain of rawDomains.split(",")) { // expects comma-separated list of domains + const domain = rawDomain.trim().replace(/^@/, "").toLowerCase(); + if (domain === "") { + continue; + } + if (!emailDomainRegex.test(domain)) { + throw new StatusError(StatusError.BadRequest, "excluded_email_domains must be a comma-separated list of valid domains"); + } + normalizedDomains.set(domain, true); + } + + if (normalizedDomains.size > maxExcludedEmailDomains) { + throw new StatusError(StatusError.BadRequest, `excluded_email_domains cannot contain more than ${maxExcludedEmailDomains} domains`); + } + + return [...normalizedDomains.keys()]; +} + async function createPersonalTeamIfEnabled(prisma: PrismaClientTransaction, tenancy: Tenancy, user: UsersCrud["Admin"]["Read"]) { if (tenancy.config.teams.createPersonalTeamOnSignUp) { const team = await teamsCrudHandlers.adminCreate({ @@ -525,6 +554,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC order_by: yupString().oneOf(['signed_up_at', 'last_active_at']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The field to sort the results by. Defaults to signed_up_at" } }), desc: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to sort the results in descending order. Defaults to false" } }), query: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "A search query to filter the results by. This is a free-text search that is applied to the user's id (exact-match only), display name and primary email." } }), + excluded_email_domains: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "A comma-separated list of primary email domains to exclude from the results." } }), include_anonymous: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to include anonymous users in the results. When true, also includes restricted users. Defaults to false" } }), only_anonymous: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to return only anonymous users. When true, implies include_anonymous=true. Defaults to false" } }), include_restricted: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to include restricted users in the results. Defaults to false" } }), @@ -538,6 +568,9 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }, onList: async ({ auth, query }) => { const queryWithoutSpecialChars = query.query?.replace(/[^a-zA-Z0-9\-_.]/g, ''); + // normalization happens once at the start of the list handling. + const excludedEmailDomains = normalizeExcludedEmailDomains(query.excluded_email_domains); + const prisma = await getPrismaClientForTenancy(auth.tenancy); // Filtering hierarchy: @@ -589,6 +622,23 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC ...shouldFilterRestrictedByAdmin ? { restrictedByAdmin: false, } : {}, + ...excludedEmailDomains.length > 0 ? { + NOT: { + contactChannels: { + some: { + type: 'EMAIL' as const, + isPrimary: 'TRUE' as const, + OR: excludedEmailDomains.map((domain) => ({ + value: { + // Exact-domain match: @gmail.com matches user@gmail.com, not user@mail.gmail.com. + endsWith: `@${domain}`, + mode: 'insensitive' as const, + }, + })), + }, + }, + }, + } : {}, ...query.query ? { OR: [ ...isUuid(queryWithoutSpecialChars!) ? [{ diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx index 46e495003..c496a3eee 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx @@ -9,10 +9,14 @@ import { Envelope } from "@phosphor-icons/react"; import { AdminEmailOutbox } from "@hexclave/next"; import { DataGrid, + applyQuickSearch, + buildRowComparator, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource, + type DataGridExportField, + type DataGridExportScope, } from "@hexclave/dashboard-ui-components"; import { useCallback, useMemo, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; @@ -115,6 +119,15 @@ const emailTableColumns: DataGridColumnDef[] = [ const OUTBOX_PAGE_SIZE = 50; +const EMAIL_EXPORT_FIELDS: DataGridExportField[] = [ + { key: "id", label: "Email ID", enabled: true, getValue: (email) => email.id }, + { key: "subject", label: "Subject", enabled: true, getValue: (email) => getSubjectDisplay(email) }, + { key: "recipient", label: "Recipient", enabled: true, getValue: (email) => getRecipientDisplay(email) }, + { key: "status", label: "Status", enabled: true, getValue: (email) => STATUS_LABELS[email.status] }, + { key: "scheduledAt", label: "Scheduled At", enabled: true, getValue: (email) => email.scheduledAt.toISOString() }, + { key: "createdAt", label: "Created At", enabled: true, getValue: (email) => email.createdAt.toISOString() }, +]; + function EmailSendDataTable() { const hexclaveAdminApp = useAdminApp(); const router = useRouter(); @@ -152,6 +165,34 @@ function EmailSendDataTable() { paginationMode: "infinite", }); + const fetchExportRows = useCallback(async (options: { + scope: DataGridExportScope, + onProgress: (fetched: number) => void, + }) => { + const allEmails: AdminEmailOutbox[] = []; + let cursor: string | undefined = undefined; + const limit = 100; + + do { + const result = await hexclaveAdminApp.listOutboxEmails({ + limit, + cursor, + }); + + allEmails.push(...result.items); + options.onProgress(allEmails.length); + cursor = result.nextCursor ?? undefined; + } while (cursor); + + if (options.scope === "filtered") { + const searchedEmails = applyQuickSearch(allEmails, gridState.quickSearch, emailTableColumns); + const comparator = buildRowComparator(gridState.sorting, emailTableColumns); + return comparator == null ? searchedEmails : [...searchedEmails].sort(comparator); + } + + return allEmails; + }, [gridState.quickSearch, gridState.sorting, hexclaveAdminApp]); + if (gridData.isLoading) { return (
@@ -179,6 +220,19 @@ function EmailSendDataTable() { onLoadMore={gridData.loadMore} fillHeight={false} footer={false} + exportOptions={{ + title: "Export Sent Emails", + description: "Configure and download sent email log data from your project", + entityName: "email", + entityNamePlural: "emails", + filenamePrefix: "stack-email-sent-export", + fields: EMAIL_EXPORT_FIELDS, + fetchRows: fetchExportRows, + emptyExportTitle: "No emails to export", + emptyExportDescription: "There are no emails matching the current filters", + allScopeLabel: "Export all emails in the project", + filteredScopeLabel: "Export only filtered/searched emails", + }} onRowClick={(row) => { router.push(`email-viewer/${row.id}`); }} 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 7fbea2814..e98862fba 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 @@ -1,13 +1,12 @@ "use client"; import { UserTable } from "@/components/data-table/user-table"; -import { ExportUsersDialog } from "@/components/export-users-dialog"; import { StyledLink } from "@/components/link"; import { Alert, Button, SimpleTooltip, Skeleton } from "@/components/ui"; import { UserDialog } from "@/components/user-dialog"; import { useMetricsUserCountsOrThrow } from "@/lib/hexclave-app-internals"; import { captureError } from "@hexclave/shared/dist/utils/errors"; -import { ArrowsClockwiseIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; import { ErrorBoundary } from "next/dist/client/components/error-boundary"; import { Suspense, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; @@ -59,12 +58,6 @@ function TotalUsersErrorComponent(props: { error: Error }) { export default function PageClient() { const hexclaveAdminApp = useAdminApp(); const firstUserPage = hexclaveAdminApp.useUsers({ limit: 1 }); - const [exportOptions, setExportOptions] = useState<{ - search?: string, - includeRestricted: boolean, - includeAnonymous: boolean, - onlyAnonymous: boolean, - }>({ includeRestricted: false, includeAnonymous: false, onlyAnonymous: false }); const [refreshKey, setRefreshKey] = useState(0); const handleRefresh = async () => { @@ -91,15 +84,6 @@ export default function PageClient() { - - - Export - - } - exportOptions={exportOptions} - /> Create User} @@ -116,7 +100,7 @@ export default function PageClient() {
- +
diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index d487ea968..a2299cc96 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -417,6 +417,21 @@ body:has(.show-site-loading-indicator) .site-loading-indicator { } } +@keyframes data-grid-export-progress-shimmer { + 0% { + transform: translateX(-105%); + } + + 100% { + transform: translateX(245%); + } +} + +.data-grid-export-progress-shimmer { + animation: data-grid-export-progress-shimmer 1.35s ease-in-out infinite; + will-change: transform; +} + @keyframes rainbow-beam { 0% { background-position: 0% 50%; @@ -429,6 +444,13 @@ body:has(.show-site-loading-indicator) .site-loading-indicator { } } +@media (prefers-reduced-motion: reduce) { + .data-grid-export-progress-shimmer { + animation: none; + transform: translateX(75%); + } +} + /* Pacifica styles */ [data-pacifica-surface] { diff --git a/apps/dashboard/src/components/data-table/team-table.tsx b/apps/dashboard/src/components/data-table/team-table.tsx index 4a682cfd7..ad0f51ef0 100644 --- a/apps/dashboard/src/components/data-table/team-table.tsx +++ b/apps/dashboard/src/components/data-table/team-table.tsx @@ -9,6 +9,8 @@ import { useDataSource, type DataGridColumnDef, type DataGridDataSource, + type DataGridExportField, + type DataGridExportScope, } from "@hexclave/dashboard-ui-components"; import React, { useCallback, useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; @@ -155,6 +157,12 @@ const columns: DataGridColumnDef[] = [ }, ]; +const TEAM_EXPORT_FIELDS: DataGridExportField[] = [ + { key: "id", label: "Team ID", enabled: true, getValue: (team) => team.id }, + { key: "displayName", label: "Display Name", enabled: true, getValue: (team) => team.displayName }, + { key: "createdAt", label: "Created At", enabled: true, getValue: (team) => new Date(team.createdAt).toISOString() }, +]; + export function TeamTable() { const router = useRouter(); const hexclaveAdminApp = useAdminApp(); @@ -167,6 +175,7 @@ export function TeamTable() { }); const [debouncedQuickSearch] = useDebounce(gridState.quickSearch.trim(), SEARCH_DEBOUNCE_MS); + const createdAtOrder = gridState.sorting.find((s) => s.columnId === "createdAt")?.direction ?? "desc"; const dataSource = useMemo>( () => async function* (params) { @@ -204,6 +213,32 @@ export function TeamTable() { paginationMode: "infinite", }); + const fetchExportRows = useCallback(async (options: { + scope: DataGridExportScope, + onProgress: (fetched: number) => void, + }) => { + const allTeams: ServerTeam[] = []; + let cursor: string | undefined = undefined; + const limit = 100; + const useFilters = options.scope === "filtered"; + + do { + const batch = await hexclaveAdminApp.listTeams({ + limit, + orderBy: "createdAt", + desc: createdAtOrder !== "asc", + cursor, + query: useFilters ? (debouncedQuickSearch || undefined) : undefined, + }); + + allTeams.push(...batch); + options.onProgress(allTeams.length); + cursor = batch.nextCursor ?? undefined; + } while (cursor); + + return allTeams; + }, [createdAtOrder, debouncedQuickSearch, hexclaveAdminApp]); + return ( + Export only filtered/searched teams + {debouncedQuickSearch && ( + + (search: "{debouncedQuickSearch}") + + )} + + ), + }} onRowClick={(row) => { router.push(`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/teams/${encodeURIComponent(row.id)}`); }} diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 2f332f759..839b79729 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -8,7 +8,7 @@ import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-a import { ActionCell, ActionDialog, Alert, AlertDescription, AvatarCell, Badge, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip } from '@/components/ui'; import type { Icon as PhosphorIcon } from '@phosphor-icons/react'; import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, GearIcon, ProhibitIcon, QuestionIcon, ReceiptXIcon, ShoppingCartIcon, ShuffleIcon } from '@phosphor-icons/react'; -import { DataGrid, DataGridToolbar, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource } from '@hexclave/dashboard-ui-components'; +import { DataGrid, DataGridToolbar, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource, type DataGridExportField, type DataGridExportScope } from '@hexclave/dashboard-ui-components'; import type { Transaction, TransactionEntry, TransactionType } from '@hexclave/shared/dist/interface/crud/transactions'; import { TRANSACTION_TYPES } from '@hexclave/shared/dist/interface/crud/transactions'; import { moneyAmountSchema } from '@hexclave/shared/dist/schema-fields'; @@ -400,6 +400,29 @@ type FilterState = { const PAGE_SIZE = 25; const CUSTOMER_TYPE_OPTIONS = ["user", "team", "custom"] as const satisfies ReadonlyArray>; +const transactionExportSummaryCache = new WeakMap(); +function getTransactionExportSummary(transaction: Transaction): TransactionSummary { + const cached = transactionExportSummaryCache.get(transaction); + if (cached != null) { + return cached; + } + + const summary = getTransactionSummary(transaction); + transactionExportSummaryCache.set(transaction, summary); + return summary; +} + +const TRANSACTION_EXPORT_FIELDS: DataGridExportField[] = [ + { key: "id", label: "Transaction ID", enabled: true, getValue: (transaction) => transaction.id }, + { key: "type", label: "Type", enabled: true, getValue: (transaction) => getTransactionExportSummary(transaction).displayType.label }, + { key: "customerType", label: "Customer Type", enabled: true, getValue: (transaction) => getTransactionExportSummary(transaction).customerType ?? "" }, + { key: "customerId", label: "Customer ID", enabled: true, getValue: (transaction) => getTransactionExportSummary(transaction).customerId ?? "" }, + { key: "amount", label: "Amount", enabled: true, getValue: (transaction) => getTransactionExportSummary(transaction).amountDisplay }, + { key: "detail", label: "Detail", enabled: true, getValue: (transaction) => getTransactionExportSummary(transaction).detail }, + { key: "createdAt", label: "Created At", enabled: true, getValue: (transaction) => new Date(transaction.created_at_millis).toISOString() }, + { key: "refunded", label: "Refunded", enabled: true, getValue: (transaction) => getTransactionExportSummary(transaction).refunded ? "Yes" : "No" }, +]; + export function TransactionTable() { const [filters, setFilters] = useState({}); @@ -621,6 +644,31 @@ function TransactionTableBody(props: { }); }, [setFilters]); + const fetchExportRows = useCallback(async (options: { + scope: DataGridExportScope, + onProgress: (fetched: number) => void, + }) => { + const allTransactions: Transaction[] = []; + let cursor: string | undefined = undefined; + const limit = 100; + const useFilters = options.scope === "filtered"; + + do { + const result = await app.listTransactions({ + limit, + cursor, + type: useFilters ? filters.type : undefined, + customerType: useFilters ? filters.customerType : undefined, + }); + + allTransactions.push(...result.transactions); + options.onProgress(allTransactions.length); + cursor = result.nextCursor ?? undefined; + } while (cursor); + + return allTransactions; + }, [app, filters.customerType, filters.type]); + return ( ( domain !== ""); + const invalidDomain = domains.find((domain) => !emailDomainRegex.test(domain)); + if (invalidDomain != null) { + return { + domains: [], + error: `Use exact domains like gmail.com. "${invalidDomain}" is not valid.`, + }; + } + return { + domains, + error: null, + }; +} + // ─── Column definitions ────────────────────────────────────────────── const USER_TABLE_COLUMNS: DataGridColumnDef[] = [ @@ -166,23 +197,30 @@ const USER_TABLE_COLUMNS: DataGridColumnDef[] = [ }, ]; +const USER_EXPORT_FIELDS: DataGridExportField[] = [ + { key: "id", label: "User ID", enabled: true, getValue: (user) => user.id }, + { key: "displayName", label: "Display Name", enabled: true, getValue: (user) => user.displayName ?? "" }, + { key: "primaryEmail", label: "Email", enabled: true, getValue: (user) => user.primaryEmail ?? "" }, + { key: "primaryEmailVerified", label: "Email Verified", enabled: true, getValue: (user) => user.primaryEmailVerified ? "Yes" : "No" }, + { key: "signedUpAt", label: "Signed Up At", enabled: true, getValue: (user) => new Date(user.signedUpAt).toISOString() }, + { key: "lastActiveAt", label: "Last Active At", enabled: true, getValue: (user) => new Date(user.lastActiveAt).toISOString() }, + { key: "isAnonymous", label: "Is Anonymous", enabled: false, getValue: (user) => user.isAnonymous ? "Yes" : "No" }, + { key: "hasPassword", label: "Has Password", enabled: false, getValue: (user) => user.hasPassword ? "Yes" : "No" }, + { key: "otpAuthEnabled", label: "OTP Auth Enabled", enabled: false, getValue: (user) => user.otpAuthEnabled ? "Yes" : "No" }, + { key: "passkeyAuthEnabled", label: "Passkey Auth Enabled", enabled: false, getValue: (user) => user.passkeyAuthEnabled ? "Yes" : "No" }, + { key: "isMultiFactorRequired", label: "Multi-Factor Required", enabled: false, getValue: (user) => user.isMultiFactorRequired ? "Yes" : "No" }, + { key: "oauthProviders", label: "OAuth Providers", enabled: false, getValue: (user) => user.oauthProviders.map((provider) => provider.id).join(", ") }, + { key: "profileImageUrl", label: "Profile Image URL", enabled: false, getValue: (user) => user.profileImageUrl ?? "" }, + { key: "clientMetadata", label: "Client Metadata", enabled: false, getValue: (user) => JSON.stringify(user.clientMetadata ?? {}) }, + { key: "clientReadOnlyMetadata", label: "Client Read-Only Metadata", enabled: false, getValue: (user) => JSON.stringify(user.clientReadOnlyMetadata ?? {}) }, + { key: "serverMetadata", label: "Server Metadata", enabled: false, getValue: (user) => JSON.stringify(user.serverMetadata ?? {}) }, +]; + // ─── UserTable ─────────────────────────────────────────────────────── -export function UserTable(props?: { - onFilterChange?: (filters: { search?: string, includeRestricted: boolean, includeAnonymous: boolean, onlyAnonymous: boolean }) => void, -}) { +export function UserTable() { const [filters, setFilters] = useState(DEFAULT_FILTERS); - const onFilterChange = props?.onFilterChange; - useEffect(() => { - onFilterChange?.({ - search: filters.search || undefined, - includeRestricted: filters.includeRestricted, - includeAnonymous: filters.includeAnonymous, - onlyAnonymous: filters.onlyAnonymous, - }); - }, [filters.search, filters.includeRestricted, filters.includeAnonymous, filters.onlyAnonymous, onFilterChange]); - return ; } @@ -247,6 +285,7 @@ function UserTableBody(props: { orderBy, desc: sortDesc, query: search, + excludedEmailDomains: filters.excludedEmailDomains, includeRestricted: filters.includeRestricted, includeAnonymous: true, onlyAnonymous: true, @@ -257,6 +296,7 @@ function UserTableBody(props: { orderBy, desc: sortDesc, query: search, + excludedEmailDomains: filters.excludedEmailDomains, includeRestricted: filters.includeRestricted, includeAnonymous: filters.includeAnonymous, cursor, @@ -267,7 +307,7 @@ function UserTableBody(props: { nextCursor: result.nextCursor ?? undefined, }; }, - [hexclaveAdminApp, filters.includeRestricted, filters.includeAnonymous, filters.onlyAnonymous], + [hexclaveAdminApp, filters.includeRestricted, filters.includeAnonymous, filters.onlyAnonymous, filters.excludedEmailDomains], ); const getRowId = useCallback((row: ExtendedServerUser) => row.id, []); @@ -292,6 +332,71 @@ function UserTableBody(props: { }, [setFilters, setGridState]); const filterValue = filters.onlyAnonymous ? "anonymous-only" : filters.includeAnonymous ? "anonymous" : filters.includeRestricted ? "restricted" : "standard"; + const fetchExportRows = useCallback(async (options: { + scope: DataGridExportScope, + onProgress: (fetched: number) => void, + }) => { + const allUsers: ServerUser[] = []; + let cursor: string | undefined = undefined; + const limit = 100; + const useFilters = options.scope === "filtered"; + + do { + type ListUsersOptions = Exclude[0], undefined>; + const baseListUsersOptions = { + limit, + cursor, + query: useFilters ? (filters.search || undefined) : undefined, + excludedEmailDomains: useFilters ? filters.excludedEmailDomains : undefined, + includeRestricted: useFilters ? filters.includeRestricted : undefined, + orderBy: "signedUpAt", + desc: true, + } satisfies Omit; + const listUsersOptions: ListUsersOptions = useFilters && filters.onlyAnonymous + ? { ...baseListUsersOptions, includeAnonymous: true, onlyAnonymous: true } + : { ...baseListUsersOptions, includeAnonymous: useFilters ? filters.includeAnonymous : true }; + const batch = await hexclaveAdminApp.listUsers(listUsersOptions); + + allUsers.push(...batch); + options.onProgress(allUsers.length); + cursor = batch.nextCursor ?? undefined; + } while (cursor); + + return extendUsers(allUsers); + }, [hexclaveAdminApp, filters.excludedEmailDomains, filters.includeAnonymous, filters.includeRestricted, filters.onlyAnonymous, filters.search]); + + const toolbarExtra = ( +
+ setFilters((prev) => ({ ...prev, excludedEmailDomains }))} + /> + +
+ ); return ( { - if (value === "anonymous-only") { - setFilters((prev) => ({ ...prev, includeRestricted: true, includeAnonymous: true, onlyAnonymous: true })); - } else if (value === "anonymous") { - setFilters((prev) => ({ ...prev, includeRestricted: true, includeAnonymous: true, onlyAnonymous: false })); - } else if (value === "restricted") { - setFilters((prev) => ({ ...prev, includeRestricted: true, includeAnonymous: false, onlyAnonymous: false })); - } else { - setFilters((prev) => ({ ...prev, includeRestricted: false, includeAnonymous: false, onlyAnonymous: false })); - } - }} - > - - - - - Exclude restricted - Signups - Signups & anonymous - Only anonymous - - - } + toolbarExtra={toolbarExtra} + exportOptions={{ + title: "Export Users", + description: "Configure and download user data from your project", + entityName: "user", + entityNamePlural: "users", + filenamePrefix: "stack-users-export", + fields: USER_EXPORT_FIELDS, + fetchRows: fetchExportRows, + emptyExportTitle: "No users to export", + emptyExportDescription: "There are no users matching the current filters", + allScopeLabel: "Export all users in the project", + filteredScopeLabel: ( + <> + Export only filtered/searched users + {filters.search && ( + + (search: "{filters.search}") + + )} + + ), + }} onRowClick={(row) => { router.push(`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(row.id)}`); }} @@ -359,6 +460,138 @@ function UserTableBody(props: { ); } +function EmailDomainFilter(props: { + domains: string[], + onChange: (domains: string[]) => void, +}) { + const { domains, onChange } = props; + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + + const addDomains = useCallback((rawInput: string) => { + const parsed = parseEmailDomains(rawInput); + if (parsed.error != null) { + setError(parsed.error); + return; + } + if (parsed.domains.length === 0) { + setInput(""); + setError(null); + return; + } + + const nextDomains = new Map(domains.map((domain) => [domain, true])); + for (const domain of parsed.domains) { + nextDomains.set(domain, true); + } + if (nextDomains.size > maxExcludedEmailDomains) { + setError(`You can exclude at most ${maxExcludedEmailDomains} domains.`); + return; + } + onChange([...nextDomains.keys()]); + setInput(""); + setError(null); + }, [domains, onChange]); + + const removeDomain = useCallback((domainToRemove: string) => { + onChange(domains.filter((domain) => domain !== domainToRemove)); + setError(null); + }, [domains, onChange]); + + const active = domains.length > 0; + + return ( + + + + + +
+
+
Exclude email domains
+

+ Hide users whose primary email uses one of these domains. +

+
+ { + setInput(event.target.value); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + addDomains(input); + } + }} + onPaste={(event) => { + const pastedText = event.clipboardData.getData("text"); + if (pastedText.includes(",") || pastedText.includes("\n")) { + event.preventDefault(); + addDomains(pastedText); + } + }} + onBlur={() => { + if (input.trim() !== "") { + addDomains(input); + } + }} + /> + {error != null ? ( +
{error}
+ ) : null} + {domains.length > 0 ? ( +
+ {domains.map((domain) => ( + + {domain} + + + ))} +
+ ) : ( +
No domains excluded.
+ )} + {domains.length > 0 ? ( +
+ +
+ ) : null} +
+
+
+ ); +} + // ─── Cell components ───────────────────────────────────────────────── function UserActions(props: { user: ExtendedServerUser }) { diff --git a/apps/dashboard/src/components/export-users-dialog.tsx b/apps/dashboard/src/components/export-users-dialog.tsx deleted file mode 100644 index 536301ab1..000000000 --- a/apps/dashboard/src/components/export-users-dialog.tsx +++ /dev/null @@ -1,394 +0,0 @@ -"use client"; - -import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; -import { DownloadSimpleIcon } from "@phosphor-icons/react"; -import type { ServerUser } from "@hexclave/next"; -import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { - Button, - Checkbox, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - Label, - RadioGroup, - RadioGroupItem, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - toast, -} from "@/components/ui"; -import { download, generateCsv, mkConfig } from "export-to-csv"; -import { useState } from "react"; - -type ExportFormat = "csv" | "json"; -type ExportScope = "all" | "filtered"; - -type ExportField = { - key: string, - label: string, - enabled: boolean, -}; - -type ExportOptions = { - search?: string, - includeAnonymous: boolean, - onlyAnonymous?: boolean, -}; - -const DEFAULT_FIELDS: ExportField[] = [ - { key: "id", label: "User ID", enabled: true }, - { key: "displayName", label: "Display Name", enabled: true }, - { key: "primaryEmail", label: "Email", enabled: true }, - { key: "primaryEmailVerified", label: "Email Verified", enabled: true }, - { key: "signedUpAt", label: "Signed Up At", enabled: true }, - { key: "lastActiveAt", label: "Last Active At", enabled: true }, - { key: "isAnonymous", label: "Is Anonymous", enabled: false }, - { key: "hasPassword", label: "Has Password", enabled: false }, - { key: "otpAuthEnabled", label: "OTP Auth Enabled", enabled: false }, - { key: "passkeyAuthEnabled", label: "Passkey Auth Enabled", enabled: false }, - { key: "isMultiFactorRequired", label: "Multi-Factor Required", enabled: false }, - { key: "oauthProviders", label: "OAuth Providers", enabled: false }, - { key: "profileImageUrl", label: "Profile Image URL", enabled: false }, - { key: "clientMetadata", label: "Client Metadata", enabled: false }, - { key: "clientReadOnlyMetadata", label: "Client Read-Only Metadata", enabled: false }, - { key: "serverMetadata", label: "Server Metadata", enabled: false }, -]; - -export function ExportUsersDialog(props: { - trigger: React.ReactNode, - exportOptions?: ExportOptions, -}) { - const { trigger, exportOptions } = props; - const hexclaveAdminApp = useAdminApp(); - const [open, setOpen] = useState(false); - const [format, setFormat] = useState("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( - hexclaveAdminApp, - 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( - hexclaveAdminApp: ReturnType, - options?: ExportOptions -): Promise { - const allUsers: ServerUser[] = []; - let cursor: string | undefined = undefined; - const limit = 100; // Fetch in batches of 100 - - do { - const listUsersOptions: Parameters[0] = { - limit, - cursor, - query: options?.search, - includeAnonymous: options?.onlyAnonymous ? true : (options?.includeAnonymous ?? true), - orderBy: "signedUpAt", - desc: true, - }; - if (options?.onlyAnonymous) { - Object.assign(listUsersOptions, { onlyAnonymous: true }); - } - const batch = await hexclaveAdminApp.listUsers(listUsersOptions); - - 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/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts index 1e825f58f..ed160f988 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts @@ -1004,6 +1004,89 @@ describe("with server access", () => { expect(response.body).toBe("only_anonymous=true requires include_anonymous=true"); }); + it("lists users excluding exact primary email domains", async ({ expect }) => { + await Project.createAndSwitch(); + + const blockedResponse = await niceBackendFetch("/api/v1/users", { + accessType: "server", + method: "POST", + body: { + primary_email: "blocked@example.com", + primary_email_verified: true, + }, + }); + const secondaryMatchResponse = await niceBackendFetch("/api/v1/users", { + accessType: "server", + method: "POST", + body: { + primary_email: "keeper@work.example", + primary_email_verified: true, + }, + }); + const noEmailResponse = await niceBackendFetch("/api/v1/users", { + accessType: "server", + method: "POST", + body: { + display_name: "No Email", + }, + }); + await niceBackendFetch("/api/v1/contact-channels", { + accessType: "server", + method: "POST", + body: { + user_id: secondaryMatchResponse.body.id, + type: "email", + value: "secondary@gmail.com", + is_verified: true, + used_for_auth: false, + }, + }); + + const response = await niceBackendFetch("/api/v1/users?include_restricted=true&excluded_email_domains=@EXAMPLE.com,gmail.com", { + accessType: "server", + }); + + const userIds = response.body.items.map((user: { id: string }) => user.id); + expect(userIds).not.toContain(blockedResponse.body.id); + expect(userIds).toContain(secondaryMatchResponse.body.id); + expect(userIds).toContain(noEmailResponse.body.id); + }); + + it("rejects invalid excluded email domains", async ({ expect }) => { + await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/v1/users?excluded_email_domains=*.gmail.com", { + accessType: "server", + }); + + expect(response.status).toBe(400); + expect(response.body).toBe("excluded_email_domains must be a comma-separated list of valid domains"); + }); + + it("treats empty excluded email domains as omitted", async ({ expect }) => { + await Project.createAndSwitch(); + const userResponse = await niceBackendFetch("/api/v1/users", { + accessType: "server", + method: "POST", + body: { + primary_email: "included@example.com", + primary_email_verified: true, + }, + }); + + const emptyResponse = await niceBackendFetch("/api/v1/users?excluded_email_domains=", { + accessType: "server", + }); + const trailingCommaResponse = await niceBackendFetch("/api/v1/users?excluded_email_domains=,", { + accessType: "server", + }); + + expect(emptyResponse.status).toBe(200); + expect(trailingCommaResponse.status).toBe(200); + expect(emptyResponse.body.items.map((user: { id: string }) => user.id)).toContain(userResponse.body.id); + expect(trailingCommaResponse.body.items.map((user: { id: string }) => user.id)).toContain(userResponse.body.id); + }); + it("lists users with pagination", async ({ expect }) => { await Project.createAndSwitch(); for (let i = 0; i < 5; i++) { diff --git a/apps/e2e/tests/js/list-users.test.ts b/apps/e2e/tests/js/list-users.test.ts index 1fd930c68..674189024 100644 --- a/apps/e2e/tests/js/list-users.test.ts +++ b/apps/e2e/tests/js/list-users.test.ts @@ -72,3 +72,46 @@ it("should list only anonymous users when onlyAnonymous is true", async ({ expec expect(anonymousOnlyUserIds).toContain(anonymousUser2.id); expect(anonymousOnlyUserIds).not.toContain(regularUser.id); }); + +it("should exclude users by primary email domain", async ({ expect }) => { + const { serverApp } = await createApp(); + + const gmailUser = await serverApp.createUser({ + primaryEmail: "blocked@gmail.com", + primaryEmailVerified: true, + }); + const yahooUser = await serverApp.createUser({ + primaryEmail: "blocked@yahoo.com", + primaryEmailVerified: true, + }); + const companyUser = await serverApp.createUser({ + primaryEmail: "kept@company.example", + primaryEmailVerified: true, + }); + const secondaryMatchUser = await serverApp.createUser({ + primaryEmail: "secondary@company.example", + primaryEmailVerified: true, + }); + await secondaryMatchUser.createContactChannel({ + type: "email", + value: "secondary@gmail.com", + isVerified: true, + usedForAuth: false, + }); + const noEmailUser = await serverApp.createUser({ + displayName: "No Email", + }); + + const users = await serverApp.listUsers({ + includeRestricted: true, + excludedEmailDomains: ["gmail.com", "YAHOO.com"], + orderBy: "signedUpAt", + }); + const userIds = users.map((user) => user.id); + + expect(userIds).not.toContain(gmailUser.id); + expect(userIds).not.toContain(yahooUser.id); + expect(userIds).toContain(companyUser.id); + expect(userIds).toContain(secondaryMatchUser.id); + expect(userIds).toContain(noEmailUser.id); +}); diff --git a/docs-mintlify/openapi/admin.json b/docs-mintlify/openapi/admin.json index f8c5a564d..01c2681f8 100644 --- a/docs-mintlify/openapi/admin.json +++ b/docs-mintlify/openapi/admin.json @@ -8807,6 +8807,16 @@ "description": "A search query to filter the results by. This is a free-text search that is applied to the user's id (exact-match only), display name and primary email.", "required": false }, + { + "name": "excluded_email_domains", + "in": "query", + "schema": { + "type": "string", + "description": "A comma-separated list of primary email domains to exclude from the results." + }, + "description": "A comma-separated list of primary email domains to exclude from the results.", + "required": false + }, { "name": "include_anonymous", "in": "query", diff --git a/docs-mintlify/openapi/server.json b/docs-mintlify/openapi/server.json index f7dd5a018..3957962cd 100644 --- a/docs-mintlify/openapi/server.json +++ b/docs-mintlify/openapi/server.json @@ -8110,6 +8110,16 @@ "description": "A search query to filter the results by. This is a free-text search that is applied to the user's id (exact-match only), display name and primary email.", "required": false }, + { + "name": "excluded_email_domains", + "in": "query", + "schema": { + "type": "string", + "description": "A comma-separated list of primary email domains to exclude from the results." + }, + "description": "A comma-separated list of primary email domains to exclude from the results.", + "required": false + }, { "name": "include_anonymous", "in": "query", diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid-export-dialog.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid-export-dialog.tsx new file mode 100644 index 000000000..4c4e3d722 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid-export-dialog.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { DownloadSimpleIcon } from "@phosphor-icons/react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { DesignButton } from "../button"; +import { DesignDialog } from "../dialog"; +import { formatGridDate, resolveColumnValue } from "./state"; +import type { + DataGridColumnDef, + DataGridExportField, + DataGridExportFormat, + DataGridExportOptions, + DataGridExportScope, +} from "./types"; + +type ExportProgress = { + phase: "idle" | "fetching" | "generating" | "complete"; + fetched: number; +}; + +type ExportCellValue = string | number | boolean | null | undefined; +type ExportTable = { + csvHeaders: string[]; + jsonKeys: string[]; + rows: ExportCellValue[][]; +}; + +type DataGridExportDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + rows: readonly TRow[]; + columns: readonly DataGridColumnDef[]; + exportFilename: string; + exportOptions?: DataGridExportOptions; +}; + +const idleExportProgress: ExportProgress = { + phase: "idle", + fetched: 0, +}; +const exportCompletionDisplayMs = 800; + +export function DataGridExportDialog({ + open, + onOpenChange, + rows, + columns, + exportFilename, + exportOptions, +}: DataGridExportDialogProps) { + const hasServerExport = exportOptions?.fetchRows != null; + const resolvedFields = useMemo( + () => exportOptions?.fields ?? buildColumnExportFields(columns), + [exportOptions?.fields, columns], + ); + const [format, setFormat] = useState("csv"); + const [scope, setScope] = useState("all"); + const [fields, setFields] = useState[]>(resolvedFields); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(idleExportProgress); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (!isExporting) { + setFields(resolvedFields); + } + }, [isExporting, resolvedFields]); + + const entityName = exportOptions?.entityName ?? "row"; + const entityNamePlural = exportOptions?.entityNamePlural ?? "rows"; + const filenamePrefix = exportOptions?.filenamePrefix ?? exportFilename; + const title = exportOptions?.title ?? "Export data"; + const description = exportOptions?.description ?? ( + hasServerExport + ? "Configure and download data from this table" + : "Configure and download the rows currently loaded in this table" + ); + const allScopeLabel = exportOptions?.allScopeLabel ?? `Export all ${entityNamePlural} in the project`; + const filteredScopeLabel = exportOptions?.filteredScopeLabel ?? `Export only filtered/searched ${entityNamePlural}`; + const progressSubjectLabel = exportOptions?.progressSubjectLabel ?? entityNamePlural; + const progressTitle = progress.phase === "complete" ? "Export complete" : `Exporting ${progressSubjectLabel}`; + const fetchExportRows = exportOptions?.fetchRows; + + const closeDialog = useCallback(() => { + onOpenChange(false); + setErrorMessage(null); + }, [onOpenChange]); + + const handleOpenChange = useCallback((nextOpen: boolean) => { + if (isExporting && !nextOpen) { + return; + } + if (nextOpen) { + onOpenChange(true); + } else { + closeDialog(); + } + }, [closeDialog, isExporting, onOpenChange]); + + const toggleField = useCallback((key: string) => { + setFields((prev) => + prev.map((field) => + field.key === key ? { ...field, enabled: !field.enabled } : field + ) + ); + }, []); + + const selectAllFields = useCallback(() => { + setFields((prev) => prev.map((field) => ({ ...field, enabled: true }))); + }, []); + + const deselectAllFields = useCallback(() => { + setFields((prev) => prev.map((field) => ({ ...field, enabled: false }))); + }, []); + + const fetchRows = useCallback(async () => { + if (fetchExportRows != null) { + return await fetchExportRows({ + scope, + onProgress: (fetched) => setProgress({ phase: "fetching", fetched }), + }); + } + + setProgress({ phase: "fetching", fetched: rows.length }); + return rows; + }, [fetchExportRows, rows, scope]); + + const handleExport = async () => { + const enabledFields = fields.filter((field) => field.enabled); + if (enabledFields.length === 0) { + setErrorMessage("Select at least one field to export."); + return; + } + + setErrorMessage(null); + setIsExporting(true); + setProgress({ phase: "fetching", fetched: 0 }); + try { + const exportRows = await fetchRows(); + + if (exportRows.length === 0) { + setErrorMessage( + exportOptions?.emptyExportDescription + ?? `There are no ${entityNamePlural} to export.`, + ); + setIsExporting(false); + setProgress(idleExportProgress); + return; + } + + setProgress({ phase: "generating", fetched: exportRows.length }); + const transformedData = buildExportTable(exportRows, enabledFields); + + if (format === "csv") { + exportToCsv(transformedData, filenamePrefix); + } else { + exportToJson(transformedData, filenamePrefix); + } + + setProgress({ phase: "complete", fetched: exportRows.length }); + await new Promise((resolve) => setTimeout(resolve, exportCompletionDisplayMs)); + closeDialog(); + } catch { + setErrorMessage("Something went wrong while exporting. Please try again."); + } finally { + setIsExporting(false); + setProgress(idleExportProgress); + } + }; + + return ( + + {isExporting ? ( + + ) : ( +
+
+ + +
+ + {hasServerExport ? ( +
+ Export Scope + + +
+ ) : null} + +
+
+ +
+ + Select All + + + Deselect All + +
+
+
+ {fields.map((field) => ( + + ))} +
+
+ + {errorMessage != null ? ( +
+
{exportOptions?.emptyExportTitle ?? "Export unavailable"}
+
{errorMessage}
+
+ ) : null} + +
+ + Cancel + + + + Export {titleCase(entityNamePlural)} + +
+
+ )} +
+ ); +} + +function ExportProgressContent(props: { + progress: ExportProgress; + format: DataGridExportFormat; + subjectLabel: string; +}) { + const { progress, format, subjectLabel } = props; + const fileLabel = format.toUpperCase(); + const isComplete = progress.phase === "complete"; + const title = isComplete ? "Export complete" : `Exporting ${subjectLabel}`; + const description = isComplete + ? `Your ${fileLabel} is ready and the download should begin automatically.` + : `Your ${fileLabel} is being prepared from matching ${subjectLabel}.`; + const statusLabel = progress.phase === "complete" + ? "Download ready" + : progress.phase === "generating" + ? `Preparing ${fileLabel}` + : `Fetching ${subjectLabel}`; + const countLabel = `${progress.fetched.toLocaleString()} ${isComplete ? "exported" : "fetched"}`; + + return ( +
+
+

{title}

+

{description}

+
+ +
+
+ {statusLabel} + + {countLabel} + +
+
+ {isComplete ? ( +
+ ) : ( +
+ )} +
+
+ +
+ Do not reload this page until the export finishes. The download will start automatically. +
+ +
+ + Cancel + +
+
+ ); +} + +function buildColumnExportFields( + columns: readonly DataGridColumnDef[], +): readonly DataGridExportField[] { + const fields: DataGridExportField[] = []; + + for (const column of columns) { + const label = typeof column.header === "string" ? column.header.trim() : column.id; + if (label.length === 0) { + continue; + } + + fields.push({ + key: column.id, + label, + enabled: true, + getValue: (row) => formatColumnExportValue(column, row), + }); + } + + return fields; +} + +function formatColumnExportValue( + column: DataGridColumnDef, + row: TRow, +): unknown { + const value = resolveColumnValue(column, row); + if (column.formatValue != null) { + return column.formatValue(value, row); + } + if (column.type === "date" || column.type === "dateTime") { + return formatGridDate(value, "absolute", { + parseValue: column.parseValue, + dateFormat: column.dateFormat, + }).display ?? ""; + } + return value; +} + +function buildExportTable( + rows: readonly TRow[], + enabledFields: readonly DataGridExportField[], +): ExportTable { + return { + csvHeaders: enabledFields.map((field) => field.label), + jsonKeys: buildJsonKeys(enabledFields), + rows: rows.map((row) => enabledFields.map((field) => toExportCellValue(field.getValue(row)))), + }; +} + +function buildJsonKeys( + fields: readonly DataGridExportField[], +): string[] { + const labelCounts = new Map(); + for (const field of fields) { + labelCounts.set(field.label, (labelCounts.get(field.label) ?? 0) + 1); + } + + const usedKeys = new Map(); + const keys: string[] = []; + for (const field of fields) { + const baseKey = labelCounts.get(field.label) === 1 ? field.label : `${field.label} (${field.key})`; + let key = baseKey; + let suffix = 2; + while (usedKeys.has(key)) { + key = `${baseKey} ${suffix}`; + suffix++; + } + usedKeys.set(key, true); + keys.push(key); + } + + return keys; +} + +function toExportCellValue(value: unknown): ExportCellValue { + if (value == null) { + return ""; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +function exportToCsv(data: ExportTable, filenamePrefix: string) { + const csvContent = "\uFEFF" + [ + data.csvHeaders.map(escapeCsvCell).join(","), + ...data.rows.map((row) => row.map(escapeCsvCell).join(",")), + ].join("\n"); + downloadFile(csvContent, `${buildExportFilename(filenamePrefix)}.csv`, "text/csv;charset=utf-8;"); +} + +function escapeCsvCell(value: ExportCellValue): string { + const rawText = String(value ?? ""); + const text = typeof value === "string" && /^[=+\-@\t\r]/.test(rawText.trimStart()) ? `'${rawText}` : rawText; + if (text.includes(",") || text.includes('"') || text.includes("\n") || text.includes("\r")) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +function exportToJson(data: ExportTable, filenamePrefix: string) { + const rows = data.rows.map((row) => { + const jsonRow: Record = {}; + for (let i = 0; i < data.jsonKeys.length; i++) { + jsonRow[data.jsonKeys[i]] = row[i] ?? ""; + } + return jsonRow; + }); + const jsonString = JSON.stringify(rows, null, 2); + downloadFile(jsonString, `${buildExportFilename(filenamePrefix)}.json`, "application/json"); +} + +function downloadFile(content: string, filename: string, type: string) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + try { + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(url); + } +} + +function buildExportFilename(prefix: string) { + return `${prefix}-${new Date().toISOString().split("T")[0]}`; +} + +function titleCase(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); +} diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx index a61ddb271..1f7a6d2cc 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx @@ -37,8 +37,9 @@ import React, { import { DesignSkeleton } from "../skeleton"; import { DEFAULT_COL_WIDTH, clampColumnWidth, getEffectiveMaxWidth, getEffectiveMinWidth } from "./data-grid-sizing"; +import { DataGridExportDialog } from "./data-grid-export-dialog"; import { DataGridToolbar } from "./data-grid-toolbar"; -import { exportToCsv, formatGridDate, resolveColumnValue } from "./state"; +import { formatGridDate, resolveColumnValue } from "./state"; import { resolveDataGridStrings } from "./strings"; import type { DataGridCellContext, @@ -628,6 +629,7 @@ export function DataGrid(props: DataGridProps) { footer, footerExtra, exportFilename = "export", + exportOptions, strings: stringsOverride, className, onRowClick, @@ -894,24 +896,11 @@ export function DataGrid(props: DataGridProps) { ); }, [rowIds, state.selection.selectedIds, fireSelection]); - // ── CSV export ─────────────────────────────────────────────── - // The grid only knows about rows currently in memory (the visible page in - // paginated mode, or the loaded prefix in infinite mode). To avoid users - // assuming "Export CSV" means "everything that exists on the server", we - // confirm with the loaded-row count before downloading. Consumers that - // want true full-dataset export can override this via a parent toolbar. + // ── Export ─────────────────────────────────────────────────── + const [exportDialogOpen, setExportDialogOpen] = useState(false); const handleExportCsv = useCallback(() => { - if (typeof window !== "undefined" && rows.length > 0) { - const totalSuffix = totalRowCount != null && totalRowCount > rows.length - ? ` of ${totalRowCount} total — load more rows first to include them` - : ""; - const confirmed = window.confirm( - `Export ${rows.length.toLocaleString()} loaded row${rows.length === 1 ? "" : "s"}${totalSuffix}?`, - ); - if (!confirmed) return; - } - exportToCsv(rows, visibleColumns, exportFilename); - }, [rows, visibleColumns, exportFilename, totalRowCount]); + setExportDialogOpen(true); + }, []); // ── Virtualizer ────────────────────────────────────────────── const scrollContainerRef = useRef(null); @@ -1019,81 +1008,90 @@ export function DataGrid(props: DataGridProps) { const isBounded = fillHeight || maxHeight != null; return ( -
+ <> +
- {toolbar !== false && ( -
- {toolbar - ? toolbar(toolbarCtx) - : ( - - )} -
+ ref={gridRef} + className={cn( + "isolate flex w-full min-w-0 max-w-full flex-col bg-transparent rounded-[calc(var(--radius)*2)]", + fillHeight ? "min-h-0 h-full" : "min-h-0 h-auto", + isBounded && "overflow-hidden", + className, )} - -
- {isRefetching && ( -
-
+ style={maxHeight != null ? { ...cssVars, maxHeight } : cssVars} + role="grid" + aria-rowcount={totalRowCount ?? rows.length} + aria-colcount={visibleColumns.length} + > +
+ {toolbar !== false && ( +
+ {toolbar + ? toolbar(toolbarCtx) + : ( + + )}
)} -
+ +
+ {isRefetching && ( +
+
+
+ )}
- {selectionMode !== "none" && ( -
- {selectionMode === "multiple" && ( - - )} -
- )} - {visibleColumns.map((col) => { - const header = headerByColId.get(col.id); - if (!header) return null; - return ; - })} +
+ {selectionMode !== "none" && ( +
+ {selectionMode === "multiple" && ( + + )} +
+ )} + {visibleColumns.map((col) => { + const header = headerByColId.get(col.id); + if (!header) return null; + return ; + })} +
-
-
(props: DataGridProps) { "[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]", )} - onScroll={handleBodyScroll} - > -
- {isLoading && ( -
- {loadingState ?? Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- )} +
+ {isLoading && ( +
+ {loadingState ?? Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ )} - {!isLoading && rows.length === 0 && ( -
- {emptyState ?? strings.noData} -
- )} + {!isLoading && rows.length === 0 && ( +
+ {emptyState ?? strings.noData} +
+ )} - {!isLoading && rows.length > 0 && ( -
- {rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index] ?? throwErr( + {!isLoading && rows.length > 0 && ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index] ?? throwErr( `DataGrid: virtualized row index ${virtualRow.index} out of range (rows.length=${rows.length})`, ); - const rowId = getRowId(row); - const isSelected = state.selection.selectedIds.has(rowId); - const isOddRow = virtualRow.index % 2 === 1; - return ( -
(props: DataGridProps) { : "hover:bg-foreground/[0.025] dark:hover:bg-foreground/[0.04]", (selectionMode !== "none" || onRowClick) && "cursor-pointer", )} - style={{ - ...(isDynamicRowHeight - ? { minHeight: estimatedRowHeight } - : { height: fixedRowHeight }), - transform: `translateY(${virtualRow.start}px)`, - }} - onClick={(e) => { if (!shouldIgnoreRowClick(e)) handleRowClick(row, rowId, e); }} - onDoubleClick={(e) => { if (!shouldIgnoreRowClick(e)) onRowDoubleClick?.(row, rowId, e); }} - role="row" - aria-rowindex={virtualRow.index + 2} - aria-selected={isSelected} - data-row-id={rowId} - data-state={isSelected ? "selected" : undefined} - > - {selectionMode !== "none" && ( -
- handleRowClick(row, rowId, event)} - ariaLabel={`Select row ${rowId}`} + style={{ + ...(isDynamicRowHeight + ? { minHeight: estimatedRowHeight } + : { height: fixedRowHeight }), + transform: `translateY(${virtualRow.start}px)`, + }} + onClick={(e) => { if (!shouldIgnoreRowClick(e)) handleRowClick(row, rowId, e); }} + onDoubleClick={(e) => { if (!shouldIgnoreRowClick(e)) onRowDoubleClick?.(row, rowId, e); }} + role="row" + aria-rowindex={virtualRow.index + 2} + aria-selected={isSelected} + data-row-id={rowId} + data-state={isSelected ? "selected" : undefined} + > + {selectionMode !== "none" && ( +
+ handleRowClick(row, rowId, event)} + ariaLabel={`Select row ${rowId}`} + /> +
+ )} + {visibleColumns.map((col) => ( + -
- )} - {visibleColumns.map((col) => ( - - ))} -
- ); - })} -
- )} + ))} +
+ ); + })} +
+ )} - {paginationMode === "infinite" && hasMore && !isLoading && ( - - )} + {paginationMode === "infinite" && hasMore && !isLoading && ( + + )} +
+ + {footer !== false && ( +
+ {footer ? footer(footerCtx) : } + {footerExtra && (typeof footerExtra === "function" ? footerExtra(footerCtx) : footerExtra)} +
+ )}
- - {footer !== false && ( -
- {footer ? footer(footerCtx) : } - {footerExtra && (typeof footerExtra === "function" ? footerExtra(footerCtx) : footerExtra)} -
- )} -
+ ); } diff --git a/packages/dashboard-ui-components/src/components/data-grid/index.ts b/packages/dashboard-ui-components/src/components/data-grid/index.ts index d5724cc62..19c57e4fe 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/index.ts +++ b/packages/dashboard-ui-components/src/components/data-grid/index.ts @@ -56,6 +56,11 @@ export type { DataGridFetchParams, DataGridFetchResult, DataGridDataSource, + DataGridExportField, + DataGridExportFormat, + DataGridExportOptions, + DataGridExportRowsOptions, + DataGridExportScope, DataGridCallbacks, DataGridProps, DataGridToolbarContext, diff --git a/packages/dashboard-ui-components/src/components/data-grid/types.ts b/packages/dashboard-ui-components/src/components/data-grid/types.ts index 05cc86f0f..965e90894 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/types.ts +++ b/packages/dashboard-ui-components/src/components/data-grid/types.ts @@ -221,6 +221,38 @@ export type DataGridDataSource = ( params: DataGridFetchParams, ) => AsyncGenerator, void, undefined>; +// ─── Export ───────────────────────────────────────────────────────── +export type DataGridExportFormat = "csv" | "json"; + +export type DataGridExportScope = "all" | "filtered"; + +export type DataGridExportField = { + key: string; + label: string; + enabled: boolean; + getValue: (row: TRow) => unknown; +}; + +export type DataGridExportRowsOptions = { + scope: DataGridExportScope; + onProgress: (fetched: number) => void; +}; + +export type DataGridExportOptions = { + title?: string; + description?: ReactNode; + entityName?: string; + entityNamePlural?: string; + filenamePrefix?: string; + fields?: readonly DataGridExportField[]; + fetchRows?: (options: DataGridExportRowsOptions) => Promise; + emptyExportTitle?: string; + emptyExportDescription?: string; + allScopeLabel?: ReactNode; + filteredScopeLabel?: ReactNode; + progressSubjectLabel?: string; +}; + // ─── Callbacks ─────────────────────────────────────────────────────── export type DataGridCallbacks = { onRowClick?: (row: TRow, rowId: RowId, event: React.MouseEvent) => void; @@ -334,6 +366,9 @@ export type DataGridProps = { /** Filename stem for CSV export (without extension). */ exportFilename?: string; + /** Full export dialog configuration. If omitted, the dialog exports the + * currently loaded rows using the grid's visible columns. */ + exportOptions?: DataGridExportOptions; /** i18n overrides. */ strings?: Partial; diff --git a/packages/shared/src/interface/server-interface.ts b/packages/shared/src/interface/server-interface.ts index 5c39b8148..bfd1b7f82 100644 --- a/packages/shared/src/interface/server-interface.ts +++ b/packages/shared/src/interface/server-interface.ts @@ -304,6 +304,7 @@ export class HexclaveServerInterface extends HexclaveClientInterface { orderBy?: 'signedUpAt' | 'lastActiveAt', desc?: boolean, query?: string, + excludedEmailDomains?: string[], includeRestricted?: boolean, teamId?: string, } @@ -332,6 +333,9 @@ export class HexclaveServerInterface extends HexclaveClientInterface { ...options.query ? { query: options.query, } : {}, + ...options.excludedEmailDomains && options.excludedEmailDomains.length > 0 ? { + excluded_email_domains: options.excludedEmailDomains.join(","), // backend expects comma-separated list of domains. + } : {}, ...options.includeRestricted ? { include_restricted: 'true', } : {}, diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts index 95427ddfb..363ff2424 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts @@ -1,5 +1,4 @@ -import { WebAuthnError, startRegistration } from "@simplewebauthn/browser"; -import { KnownErrors, HexclaveServerInterface } from "@hexclave/shared"; +import { HexclaveServerInterface, KnownErrors } from "@hexclave/shared"; import { ContactChannelsCrud } from "@hexclave/shared/dist/interface/crud/contact-channels"; import { ItemCrud } from "@hexclave/shared/dist/interface/crud/items"; import { NotificationPreferenceCrud } from "@hexclave/shared/dist/interface/crud/notification-preferences"; @@ -19,6 +18,7 @@ import { ProviderType } from "@hexclave/shared/dist/utils/oauth"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import { suspend } from "@hexclave/shared/dist/utils/react"; import { Result } from "@hexclave/shared/dist/utils/results"; +import { WebAuthnError, startRegistration } from "@simplewebauthn/browser"; import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like import * as yup from "yup"; import { constructRedirectUrl } from "../../../../utils/url"; @@ -60,14 +60,16 @@ export class _HexclaveServerAppImplIncomplete(async ([cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous, onlyAnonymous, teamId]) => { + excludedEmailDomains?: string, + ], UsersCrud['Server']['List']>(async ([cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous, onlyAnonymous, teamId, excludedEmailDomains]) => { if (onlyAnonymous && !includeAnonymous) { throw new HexclaveAssertionError("onlyAnonymous=true requires includeAnonymous=true"); } + const excludedEmailDomainList = excludedEmailDomains?.split(","); if (onlyAnonymous) { - return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous: true, onlyAnonymous: true, teamId }); + return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, excludedEmailDomains: excludedEmailDomainList, includeRestricted, includeAnonymous: true, onlyAnonymous: true, teamId }); } - return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous, teamId }); + return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, excludedEmailDomains: excludedEmailDomainList, includeRestricted, includeAnonymous, teamId }); }); private readonly _serverUserCache = createCache(async ([userId]) => { const user = await this._interface.getServerUserById(userId); @@ -1393,7 +1395,8 @@ export class _HexclaveServerAppImplIncomplete { - const crud = Result.orThrow(await this._serverUsersCache.getOrWait([options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous, options?.teamId], "write-only")); + const excludedEmailDomains = options?.excludedEmailDomains && options.excludedEmailDomains.length > 0 ? options.excludedEmailDomains.join(",") : undefined; + const crud = Result.orThrow(await this._serverUsersCache.getOrWait([options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous, options?.teamId, excludedEmailDomains], "write-only")); const result: any = crud.items.map((j) => this._serverUserFromCrud(j)); result.nextCursor = crud.pagination?.next_cursor ?? null; return result as any; @@ -1401,7 +1404,8 @@ export class _HexclaveServerAppImplIncomplete 0 ? options.excludedEmailDomains.join(",") : undefined; + const crud = useAsyncCache(this._serverUsersCache, [options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous, options?.teamId, excludedEmailDomains] as const, "serverApp.useUsers()"); const result: any = crud.items.map((j) => this._serverUserFromCrud(j)); result.nextCursor = crud.pagination?.next_cursor ?? null; return result as any; diff --git a/packages/template/src/lib/hexclave-app/teams/index.ts b/packages/template/src/lib/hexclave-app/teams/index.ts index e3e40a19c..1ba74757c 100644 --- a/packages/template/src/lib/hexclave-app/teams/index.ts +++ b/packages/template/src/lib/hexclave-app/teams/index.ts @@ -135,6 +135,10 @@ type ServerListUsersOptionsBase = { * Free-text search. Matches user ID (exact UUID), display name, and contact channels (e.g. primary email). */ query?: string, + /** + * Exclude users whose primary email domain matches one of these exact domains. + */ + excludedEmailDomains?: string[], /** * Only return users who are members of the given team. */