mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
User page email filtering (#1668)
This commit is contained in:
parent
53b0cae480
commit
164374f6c8
@ -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<string, true>();
|
||||
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!) ? [{
|
||||
|
||||
@ -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<AdminEmailOutbox>[] = [
|
||||
|
||||
const OUTBOX_PAGE_SIZE = 50;
|
||||
|
||||
const EMAIL_EXPORT_FIELDS: DataGridExportField<AdminEmailOutbox>[] = [
|
||||
{ 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 (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
@ -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}`);
|
||||
}}
|
||||
|
||||
@ -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() {
|
||||
<ArrowsClockwiseIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
<ExportUsersDialog
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<DownloadSimpleIcon className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
}
|
||||
exportOptions={exportOptions}
|
||||
/>
|
||||
<UserDialog
|
||||
type="create"
|
||||
trigger={<Button>Create User</Button>}
|
||||
@ -116,7 +100,7 @@ export default function PageClient() {
|
||||
<UsersKpiCards />
|
||||
|
||||
<div data-walkthrough="users-table">
|
||||
<UserTable key={refreshKey} onFilterChange={setExportOptions} />
|
||||
<UserTable key={refreshKey} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
|
||||
@ -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] {
|
||||
|
||||
@ -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<ServerTeam>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const TEAM_EXPORT_FIELDS: DataGridExportField<ServerTeam>[] = [
|
||||
{ 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<DataGridDataSource<ServerTeam>>(
|
||||
() => 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 (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
@ -221,6 +256,28 @@ export function TeamTable() {
|
||||
estimatedRowHeight={44}
|
||||
footer={false}
|
||||
fillHeight={false}
|
||||
exportOptions={{
|
||||
title: "Export Teams",
|
||||
description: "Configure and download team data from your project",
|
||||
entityName: "team",
|
||||
entityNamePlural: "teams",
|
||||
filenamePrefix: "stack-teams-export",
|
||||
fields: TEAM_EXPORT_FIELDS,
|
||||
fetchRows: fetchExportRows,
|
||||
emptyExportTitle: "No teams to export",
|
||||
emptyExportDescription: "There are no teams matching the current filters",
|
||||
allScopeLabel: "Export all teams in the project",
|
||||
filteredScopeLabel: (
|
||||
<>
|
||||
Export only filtered/searched teams
|
||||
{debouncedQuickSearch && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(search: "{debouncedQuickSearch}")
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
onRowClick={(row) => {
|
||||
router.push(`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/teams/${encodeURIComponent(row.id)}`);
|
||||
}}
|
||||
|
||||
@ -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<NonNullable<FilterState["customerType"]>>;
|
||||
|
||||
const transactionExportSummaryCache = new WeakMap<Transaction, TransactionSummary>();
|
||||
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<Transaction>[] = [
|
||||
{ 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<FilterState>({});
|
||||
|
||||
@ -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 (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
@ -637,7 +685,19 @@ function TransactionTableBody(props: {
|
||||
fillHeight={false}
|
||||
footer={false}
|
||||
rowHeight={56}
|
||||
|
||||
exportOptions={{
|
||||
title: "Export Transactions",
|
||||
description: "Configure and download transaction data from your project",
|
||||
entityName: "transaction",
|
||||
entityNamePlural: "transactions",
|
||||
filenamePrefix: "stack-transactions-export",
|
||||
fields: TRANSACTION_EXPORT_FIELDS,
|
||||
fetchRows: fetchExportRows,
|
||||
emptyExportTitle: "No transactions to export",
|
||||
emptyExportDescription: "There are no transactions matching the current filters",
|
||||
allScopeLabel: "Export all transactions in the project",
|
||||
filteredScopeLabel: "Export only filtered transactions",
|
||||
}}
|
||||
toolbar={(ctx) => (
|
||||
<DataGridToolbar
|
||||
ctx={ctx}
|
||||
|
||||
@ -13,6 +13,10 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
@ -21,7 +25,7 @@ import {
|
||||
SimpleTooltip,
|
||||
toast,
|
||||
} from "@/components/ui";
|
||||
import { CheckCircleIcon, CopyIcon, DotsThreeIcon, MagnifyingGlassIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { CheckCircleIcon, CopyIcon, DotsThreeIcon, FunnelSimpleIcon, MagnifyingGlassIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import type { ServerUser } from "@hexclave/next";
|
||||
import {
|
||||
DataGrid,
|
||||
@ -29,6 +33,8 @@ import {
|
||||
useDataSource,
|
||||
type DataGridColumnDef,
|
||||
type DataGridDataSource,
|
||||
type DataGridExportField,
|
||||
type DataGridExportScope,
|
||||
} from "@hexclave/dashboard-ui-components";
|
||||
import { fromNow } from "@hexclave/shared/dist/utils/dates";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
@ -52,16 +58,22 @@ type FilterState = {
|
||||
includeRestricted: boolean,
|
||||
includeAnonymous: boolean,
|
||||
onlyAnonymous: boolean,
|
||||
excludedEmailDomains: string[],
|
||||
signedUpOrder: "asc" | "desc",
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
// Keep in sync with the backend list-users 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;
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
search: "",
|
||||
includeRestricted: true,
|
||||
includeAnonymous: false,
|
||||
onlyAnonymous: false,
|
||||
excludedEmailDomains: [],
|
||||
signedUpOrder: "desc",
|
||||
};
|
||||
|
||||
@ -99,6 +111,25 @@ function formatUserId(id: string) {
|
||||
return `${id.slice(0, 6)}…${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function normalizeEmailDomain(domain: string) {
|
||||
return domain.trim().replace(/^@/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function parseEmailDomains(input: string) {
|
||||
const domains = input.split(/[,\n]+/).map(normalizeEmailDomain).filter((domain) => 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<ExtendedServerUser>[] = [
|
||||
@ -166,23 +197,30 @@ const USER_TABLE_COLUMNS: DataGridColumnDef<ExtendedServerUser>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const USER_EXPORT_FIELDS: DataGridExportField<ExtendedServerUser>[] = [
|
||||
{ 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<FilterState>(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 <UserTableBody filters={filters} setFilters={setFilters} />;
|
||||
}
|
||||
|
||||
@ -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<Parameters<typeof hexclaveAdminApp.listUsers>[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<ListUsersOptions, "includeAnonymous" | "onlyAnonymous">;
|
||||
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 = (
|
||||
<div className="flex items-center gap-2">
|
||||
<EmailDomainFilter
|
||||
domains={filters.excludedEmailDomains}
|
||||
onChange={(excludedEmailDomains) => setFilters((prev) => ({ ...prev, excludedEmailDomains }))}
|
||||
/>
|
||||
<Select
|
||||
value={filterValue}
|
||||
onValueChange={(value) => {
|
||||
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 }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-xs" aria-label="User list filter">
|
||||
<SelectValue placeholder="Signups" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="standard">Exclude restricted</SelectItem>
|
||||
<SelectItem value="restricted">Signups</SelectItem>
|
||||
<SelectItem value="anonymous">Signups & anonymous</SelectItem>
|
||||
<SelectItem value="anonymous-only">Only anonymous</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
@ -310,33 +415,29 @@ function UserTableBody(props: {
|
||||
estimatedRowHeight={44}
|
||||
footer={false}
|
||||
fillHeight={false}
|
||||
|
||||
toolbarExtra={
|
||||
<Select
|
||||
value={filterValue}
|
||||
onValueChange={(value) => {
|
||||
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 }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-xs" aria-label="User list filter">
|
||||
<SelectValue placeholder="Signups" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="standard">Exclude restricted</SelectItem>
|
||||
<SelectItem value="restricted">Signups</SelectItem>
|
||||
<SelectItem value="anonymous">Signups & anonymous</SelectItem>
|
||||
<SelectItem value="anonymous-only">Only anonymous</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
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 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(search: "{filters.search}")
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
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<string | null>(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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-xl border-black/[0.08] bg-white/85 px-3 text-xs shadow-sm ring-1 ring-black/[0.08] hover:bg-white dark:border-white/[0.06] dark:bg-foreground/[0.03] dark:ring-white/[0.06] dark:hover:bg-foreground/[0.06]"
|
||||
aria-label="Exclude email domains"
|
||||
>
|
||||
<FunnelSimpleIcon className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
Exclude by Email
|
||||
{active ? (
|
||||
<Badge variant="secondary" className="ml-2 rounded-full px-1.5 py-0 text-[10px] font-medium">
|
||||
{domains.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[320px] rounded-xl border-black/[0.08] bg-white/95 p-3 shadow-md ring-1 ring-black/[0.08] backdrop-blur-xl dark:border-white/[0.06] dark:bg-background/95 dark:ring-white/[0.06]"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Exclude email domains</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Hide users whose primary email uses one of these domains.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
size="sm"
|
||||
value={input}
|
||||
placeholder="gmail.com, yahoo.com"
|
||||
aria-label="Excluded email domains"
|
||||
onChange={(event) => {
|
||||
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 ? (
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
) : null}
|
||||
{domains.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{domains.map((domain) => (
|
||||
<Badge key={domain} variant="secondary" className="gap-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{domain}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={`Remove ${domain}`}
|
||||
>
|
||||
<XCircleIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">No domains excluded.</div>
|
||||
)}
|
||||
{domains.length > 0 ? (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => onChange([])}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cell components ─────────────────────────────────────────────────
|
||||
|
||||
function UserActions(props: { user: ExtendedServerUser }) {
|
||||
|
||||
@ -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<ExportFormat>("csv");
|
||||
const [scope, setScope] = useState<ExportScope>("all");
|
||||
const [fields, setFields] = useState<ExportField[]>(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 (
|
||||
<>
|
||||
<div onClick={() => setOpen(true)}>
|
||||
{trigger}
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Users</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure and download user data from your project
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Export Format */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Export Format</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV (Comma-separated values)</SelectItem>
|
||||
<SelectItem value="json">JSON (JavaScript Object Notation)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Export Scope */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Export Scope</Label>
|
||||
<RadioGroup value={scope} onValueChange={(v) => setScope(v as ExportScope)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="scope-all" />
|
||||
<Label htmlFor="scope-all" className="font-normal cursor-pointer">
|
||||
Export all users in the project
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="filtered" id="scope-filtered" />
|
||||
<Label htmlFor="scope-filtered" className="font-normal cursor-pointer">
|
||||
Export only filtered/searched users
|
||||
{exportOptions?.search && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(search: "{exportOptions.search}")
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Field Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Fields to Export</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectAllFields}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={deselectAllFields}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Deselect All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 max-h-[300px] overflow-y-auto border border-border rounded-lg p-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`field-${field.key}`}
|
||||
checked={field.enabled}
|
||||
onCheckedChange={() => toggleField(field.key)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`field-${field.key}`}
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isExporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => runAsynchronouslyWithAlert(handleExport)} disabled={isExporting}>
|
||||
<DownloadSimpleIcon className="mr-2 h-4 w-4" />
|
||||
{isExporting ? "Exporting..." : "Export Users"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAllUsers(
|
||||
hexclaveAdminApp: ReturnType<typeof useAdminApp>,
|
||||
options?: ExportOptions
|
||||
): Promise<ServerUser[]> {
|
||||
const allUsers: ServerUser[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
const limit = 100; // Fetch in batches of 100
|
||||
|
||||
do {
|
||||
const listUsersOptions: Parameters<typeof hexclaveAdminApp.listUsers>[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<string, unknown> {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>[]) {
|
||||
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<string, unknown>[]) {
|
||||
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);
|
||||
}
|
||||
@ -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++) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<TRow> = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
rows: readonly TRow[];
|
||||
columns: readonly DataGridColumnDef<TRow>[];
|
||||
exportFilename: string;
|
||||
exportOptions?: DataGridExportOptions<TRow>;
|
||||
};
|
||||
|
||||
const idleExportProgress: ExportProgress = {
|
||||
phase: "idle",
|
||||
fetched: 0,
|
||||
};
|
||||
const exportCompletionDisplayMs = 800;
|
||||
|
||||
export function DataGridExportDialog<TRow>({
|
||||
open,
|
||||
onOpenChange,
|
||||
rows,
|
||||
columns,
|
||||
exportFilename,
|
||||
exportOptions,
|
||||
}: DataGridExportDialogProps<TRow>) {
|
||||
const hasServerExport = exportOptions?.fetchRows != null;
|
||||
const resolvedFields = useMemo(
|
||||
() => exportOptions?.fields ?? buildColumnExportFields(columns),
|
||||
[exportOptions?.fields, columns],
|
||||
);
|
||||
const [format, setFormat] = useState<DataGridExportFormat>("csv");
|
||||
const [scope, setScope] = useState<DataGridExportScope>("all");
|
||||
const [fields, setFields] = useState<readonly DataGridExportField<TRow>[]>(resolvedFields);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [progress, setProgress] = useState<ExportProgress>(idleExportProgress);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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<void>((resolve) => setTimeout(resolve, exportCompletionDisplayMs));
|
||||
closeDialog();
|
||||
} catch {
|
||||
setErrorMessage("Something went wrong while exporting. Please try again.");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setProgress(idleExportProgress);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DesignDialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={isExporting ? progressTitle : title}
|
||||
description={isExporting ? `Preparing export for ${progressSubjectLabel}.` : description}
|
||||
size="2xl"
|
||||
variant="plain"
|
||||
headerClassName={isExporting ? "sr-only" : undefined}
|
||||
hideTopCloseButton={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<ExportProgressContent
|
||||
progress={progress}
|
||||
format={format}
|
||||
subjectLabel={progressSubjectLabel}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`${filenamePrefix}-export-format`}>
|
||||
Export Format
|
||||
</label>
|
||||
<select
|
||||
id={`${filenamePrefix}-export-format`}
|
||||
value={format}
|
||||
onChange={(event) => setFormat(event.currentTarget.value === "json" ? "json" : "csv")}
|
||||
className="h-9 w-full rounded-md border border-input bg-white px-3 text-sm dark:bg-background"
|
||||
>
|
||||
<option value="csv">CSV (Comma-separated values)</option>
|
||||
<option value="json">JSON (JavaScript Object Notation)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{hasServerExport ? (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Export Scope</legend>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name={`${filenamePrefix}-export-scope`}
|
||||
value="all"
|
||||
checked={scope === "all"}
|
||||
onChange={() => setScope("all")}
|
||||
/>
|
||||
<span>{allScopeLabel}</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name={`${filenamePrefix}-export-scope`}
|
||||
value="filtered"
|
||||
checked={scope === "filtered"}
|
||||
onChange={() => setScope("filtered")}
|
||||
/>
|
||||
<span>{filteredScopeLabel}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-sm font-medium">Fields to Export</label>
|
||||
<div className="flex gap-2">
|
||||
<DesignButton type="button" variant="ghost" size="sm" onClick={selectAllFields} className="h-7 text-xs">
|
||||
Select All
|
||||
</DesignButton>
|
||||
<DesignButton type="button" variant="ghost" size="sm" onClick={deselectAllFields} className="h-7 text-xs">
|
||||
Deselect All
|
||||
</DesignButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-[300px] grid-cols-1 gap-3 overflow-y-auto rounded-lg border border-border p-4 sm:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<label key={field.key} className="flex cursor-pointer items-center gap-2 text-sm font-normal">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.enabled}
|
||||
onChange={() => toggleField(field.key)}
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage != null ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<div className="font-medium">{exportOptions?.emptyExportTitle ?? "Export unavailable"}</div>
|
||||
<div>{errorMessage}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<DesignButton variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</DesignButton>
|
||||
<DesignButton onClick={handleExport}>
|
||||
<DownloadSimpleIcon className="mr-2 h-4 w-4" />
|
||||
Export {titleCase(entityNamePlural)}
|
||||
</DesignButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DesignDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold leading-snug">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-muted/35 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-4 text-sm">
|
||||
<span className="font-medium text-foreground">{statusLabel}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{countLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-foreground/10">
|
||||
{isComplete ? (
|
||||
<div className="h-full w-full rounded-full bg-emerald-500/80" />
|
||||
) : (
|
||||
<div className="data-grid-export-progress-shimmer absolute inset-y-0 left-0 w-2/5 rounded-full bg-gradient-to-r from-transparent via-foreground/65 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
Do not reload this page until the export finishes. The download will start automatically.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<DesignButton variant="outline" disabled>
|
||||
Cancel
|
||||
</DesignButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumnExportFields<TRow>(
|
||||
columns: readonly DataGridColumnDef<TRow>[],
|
||||
): readonly DataGridExportField<TRow>[] {
|
||||
const fields: DataGridExportField<TRow>[] = [];
|
||||
|
||||
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<TRow>(
|
||||
column: DataGridColumnDef<TRow>,
|
||||
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<TRow>(
|
||||
rows: readonly TRow[],
|
||||
enabledFields: readonly DataGridExportField<TRow>[],
|
||||
): ExportTable {
|
||||
return {
|
||||
csvHeaders: enabledFields.map((field) => field.label),
|
||||
jsonKeys: buildJsonKeys(enabledFields),
|
||||
rows: rows.map((row) => enabledFields.map((field) => toExportCellValue(field.getValue(row)))),
|
||||
};
|
||||
}
|
||||
|
||||
function buildJsonKeys<TRow>(
|
||||
fields: readonly DataGridExportField<TRow>[],
|
||||
): string[] {
|
||||
const labelCounts = new Map<string, number>();
|
||||
for (const field of fields) {
|
||||
labelCounts.set(field.label, (labelCounts.get(field.label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const usedKeys = new Map<string, true>();
|
||||
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<string, ExportCellValue> = {};
|
||||
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);
|
||||
}
|
||||
@ -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<TRow>(props: DataGridProps<TRow>) {
|
||||
footer,
|
||||
footerExtra,
|
||||
exportFilename = "export",
|
||||
exportOptions,
|
||||
strings: stringsOverride,
|
||||
className,
|
||||
onRowClick,
|
||||
@ -894,24 +896,11 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
);
|
||||
}, [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<HTMLDivElement>(null);
|
||||
@ -1019,81 +1008,90 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
const isBounded = fillHeight || maxHeight != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
style={maxHeight != null ? { ...cssVars, maxHeight } : cssVars}
|
||||
role="grid"
|
||||
aria-rowcount={totalRowCount ?? rows.length}
|
||||
aria-colcount={visibleColumns.length}
|
||||
>
|
||||
<>
|
||||
<DataGridExportDialog
|
||||
open={exportDialogOpen}
|
||||
onOpenChange={setExportDialogOpen}
|
||||
rows={rows}
|
||||
columns={visibleColumns}
|
||||
exportFilename={exportFilename}
|
||||
exportOptions={exportOptions}
|
||||
/>
|
||||
<div
|
||||
ref={stickyChromeRef}
|
||||
className="sticky z-30 w-full min-w-0 shrink-0 overflow-visible rounded-t-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl"
|
||||
style={{ top: stickyTop ?? (maxHeight != null ? 0 : "var(--data-grid-sticky-top, 0px)") }}
|
||||
>
|
||||
{toolbar !== false && (
|
||||
<div className="relative bg-transparent">
|
||||
{toolbar
|
||||
? toolbar(toolbarCtx)
|
||||
: (
|
||||
<DataGridToolbar
|
||||
ctx={toolbarCtx}
|
||||
extra={typeof toolbarExtra === "function" ? toolbarExtra(toolbarCtx) : toolbarExtra}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
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,
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{isRefetching && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 z-30 bg-foreground/[0.04] overflow-hidden">
|
||||
<div className="h-full w-1/3 bg-blue-500/60 rounded-full animate-pulse" />
|
||||
style={maxHeight != null ? { ...cssVars, maxHeight } : cssVars}
|
||||
role="grid"
|
||||
aria-rowcount={totalRowCount ?? rows.length}
|
||||
aria-colcount={visibleColumns.length}
|
||||
>
|
||||
<div
|
||||
ref={stickyChromeRef}
|
||||
className="sticky z-30 w-full min-w-0 shrink-0 overflow-visible rounded-t-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl"
|
||||
style={{ top: stickyTop ?? (maxHeight != null ? 0 : "var(--data-grid-sticky-top, 0px)") }}
|
||||
>
|
||||
{toolbar !== false && (
|
||||
<div className="relative bg-transparent">
|
||||
{toolbar
|
||||
? toolbar(toolbarCtx)
|
||||
: (
|
||||
<DataGridToolbar
|
||||
ctx={toolbarCtx}
|
||||
extra={typeof toolbarExtra === "function" ? toolbarExtra(toolbarCtx) : toolbarExtra}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={headerScrollRef}
|
||||
className="w-full min-w-0 shrink-0 overflow-hidden border-b border-foreground/[0.06]"
|
||||
>
|
||||
|
||||
<div className="relative">
|
||||
{isRefetching && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 z-30 bg-foreground/[0.04] overflow-hidden">
|
||||
<div className="h-full w-1/3 bg-blue-500/60 rounded-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex"
|
||||
style={{ height: headerHeight, minWidth: totalContentWidth }}
|
||||
role="row"
|
||||
ref={headerScrollRef}
|
||||
className="w-full min-w-0 shrink-0 overflow-hidden border-b border-foreground/[0.06]"
|
||||
>
|
||||
{selectionMode !== "none" && (
|
||||
<div
|
||||
className="flex items-center justify-center border-r border-foreground/[0.04]"
|
||||
style={{ width: 44 }}
|
||||
>
|
||||
{selectionMode === "multiple" && (
|
||||
<SelectionCheckbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={handleSelectAll}
|
||||
ariaLabel="Select all rows on this page"
|
||||
title="Select all rows on this page"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col) => {
|
||||
const header = headerByColId.get(col.id);
|
||||
if (!header) return null;
|
||||
return <HeaderCell key={col.id} header={header} col={col} resizable={resizable} />;
|
||||
})}
|
||||
<div
|
||||
className="flex"
|
||||
style={{ height: headerHeight, minWidth: totalContentWidth }}
|
||||
role="row"
|
||||
>
|
||||
{selectionMode !== "none" && (
|
||||
<div
|
||||
className="flex items-center justify-center border-r border-foreground/[0.04]"
|
||||
style={{ width: 44 }}
|
||||
>
|
||||
{selectionMode === "multiple" && (
|
||||
<SelectionCheckbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={handleSelectAll}
|
||||
ariaLabel="Select all rows on this page"
|
||||
title="Select all rows on this page"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col) => {
|
||||
const header = headerByColId.get(col.id);
|
||||
if (!header) return null;
|
||||
return <HeaderCell key={col.id} header={header} col={col} resizable={resizable} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
"relative z-0 w-full min-w-0 overflow-auto bg-transparent",
|
||||
isBounded ? "min-h-0 flex-1" : "flex-none",
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
|
||||
@ -1101,61 +1099,61 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
|
||||
)}
|
||||
onScroll={handleBodyScroll}
|
||||
>
|
||||
<div
|
||||
ref={rowsClipRef}
|
||||
data-data-grid-rows-clip=""
|
||||
className="relative z-0"
|
||||
style={{
|
||||
minWidth: totalContentWidth,
|
||||
clipPath: "inset(var(--data-grid-sticky-overlap, 0px) 0 0 0)",
|
||||
}}
|
||||
onScroll={handleBodyScroll}
|
||||
>
|
||||
{isLoading && (
|
||||
<div style={{ minWidth: totalContentWidth }}>
|
||||
{loadingState ?? Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonRow
|
||||
key={i}
|
||||
columns={visibleColumns}
|
||||
height={estimatedRowHeight}
|
||||
showCheckbox={selectionMode !== "none"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={rowsClipRef}
|
||||
data-data-grid-rows-clip=""
|
||||
className="relative z-0"
|
||||
style={{
|
||||
minWidth: totalContentWidth,
|
||||
clipPath: "inset(var(--data-grid-sticky-overlap, 0px) 0 0 0)",
|
||||
}}
|
||||
>
|
||||
{isLoading && (
|
||||
<div style={{ minWidth: totalContentWidth }}>
|
||||
{loadingState ?? Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonRow
|
||||
key={i}
|
||||
columns={visibleColumns}
|
||||
height={estimatedRowHeight}
|
||||
showCheckbox={selectionMode !== "none"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<div
|
||||
className="flex items-center justify-center py-16 text-sm text-muted-foreground"
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
{emptyState ?? strings.noData}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<div
|
||||
className="flex items-center justify-center py-16 text-sm text-muted-foreground"
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
{emptyState ?? strings.noData}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && rows.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
minWidth: totalContentWidth,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => {
|
||||
const row = rows[virtualRow.index] ?? throwErr(
|
||||
{!isLoading && rows.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
minWidth: totalContentWidth,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={rowId}
|
||||
ref={isDynamicRowHeight ? rowVirtualizer.measureElement : undefined}
|
||||
data-index={virtualRow.index}
|
||||
className={cn(
|
||||
const rowId = getRowId(row);
|
||||
const isSelected = state.selection.selectedIds.has(rowId);
|
||||
const isOddRow = virtualRow.index % 2 === 1;
|
||||
return (
|
||||
<div
|
||||
key={rowId}
|
||||
ref={isDynamicRowHeight ? rowVirtualizer.measureElement : undefined}
|
||||
data-index={virtualRow.index}
|
||||
className={cn(
|
||||
"absolute left-0 w-full flex",
|
||||
"border-b border-black/[0.03] dark:border-white/[0.03]",
|
||||
"transition-colors duration-75",
|
||||
@ -1166,66 +1164,67 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
: "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" && (
|
||||
<div
|
||||
className="flex items-center justify-center border-r border-black/[0.04] dark:border-white/[0.04]"
|
||||
style={{ width: 44 }}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={isSelected}
|
||||
onChange={(event) => 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" && (
|
||||
<div
|
||||
className="flex items-center justify-center border-r border-black/[0.04] dark:border-white/[0.04]"
|
||||
style={{ width: 44 }}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={isSelected}
|
||||
onChange={(event) => handleRowClick(row, rowId, event)}
|
||||
ariaLabel={`Select row ${rowId}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col) => (
|
||||
<DataCell
|
||||
key={col.id}
|
||||
col={col}
|
||||
row={row}
|
||||
rowId={rowId}
|
||||
rowIndex={virtualRow.index}
|
||||
isSelected={isSelected}
|
||||
dateDisplay={state.dateDisplay}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col) => (
|
||||
<DataCell
|
||||
key={col.id}
|
||||
col={col}
|
||||
row={row}
|
||||
rowId={rowId}
|
||||
rowIndex={virtualRow.index}
|
||||
isSelected={isSelected}
|
||||
dateDisplay={state.dateDisplay}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paginationMode === "infinite" && hasMore && !isLoading && (
|
||||
<InfiniteScrollSentinel
|
||||
onIntersect={onLoadMore ?? NOOP}
|
||||
isLoading={isLoadingMore}
|
||||
rootRef={infiniteScrollRootRef}
|
||||
strings={strings}
|
||||
/>
|
||||
)}
|
||||
{paginationMode === "infinite" && hasMore && !isLoading && (
|
||||
<InfiniteScrollSentinel
|
||||
onIntersect={onLoadMore ?? NOOP}
|
||||
isLoading={isLoadingMore}
|
||||
rootRef={infiniteScrollRootRef}
|
||||
strings={strings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer !== false && (
|
||||
<div className="sticky bottom-0 z-30 shrink-0 overflow-hidden rounded-b-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl">
|
||||
{footer ? footer(footerCtx) : <DefaultFooter ctx={footerCtx} pagination={paginationMode} onChange={onChange} />}
|
||||
{footerExtra && (typeof footerExtra === "function" ? footerExtra(footerCtx) : footerExtra)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footer !== false && (
|
||||
<div className="sticky bottom-0 z-30 shrink-0 overflow-hidden rounded-b-[calc(var(--radius)*2)] bg-white/90 dark:bg-background/60 backdrop-blur-xl">
|
||||
{footer ? footer(footerCtx) : <DefaultFooter ctx={footerCtx} pagination={paginationMode} onChange={onChange} />}
|
||||
{footerExtra && (typeof footerExtra === "function" ? footerExtra(footerCtx) : footerExtra)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -56,6 +56,11 @@ export type {
|
||||
DataGridFetchParams,
|
||||
DataGridFetchResult,
|
||||
DataGridDataSource,
|
||||
DataGridExportField,
|
||||
DataGridExportFormat,
|
||||
DataGridExportOptions,
|
||||
DataGridExportRowsOptions,
|
||||
DataGridExportScope,
|
||||
DataGridCallbacks,
|
||||
DataGridProps,
|
||||
DataGridToolbarContext,
|
||||
|
||||
@ -221,6 +221,38 @@ export type DataGridDataSource<TRow> = (
|
||||
params: DataGridFetchParams,
|
||||
) => AsyncGenerator<DataGridFetchResult<TRow>, void, undefined>;
|
||||
|
||||
// ─── Export ─────────────────────────────────────────────────────────
|
||||
export type DataGridExportFormat = "csv" | "json";
|
||||
|
||||
export type DataGridExportScope = "all" | "filtered";
|
||||
|
||||
export type DataGridExportField<TRow> = {
|
||||
key: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
getValue: (row: TRow) => unknown;
|
||||
};
|
||||
|
||||
export type DataGridExportRowsOptions = {
|
||||
scope: DataGridExportScope;
|
||||
onProgress: (fetched: number) => void;
|
||||
};
|
||||
|
||||
export type DataGridExportOptions<TRow> = {
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
entityName?: string;
|
||||
entityNamePlural?: string;
|
||||
filenamePrefix?: string;
|
||||
fields?: readonly DataGridExportField<TRow>[];
|
||||
fetchRows?: (options: DataGridExportRowsOptions) => Promise<readonly TRow[]>;
|
||||
emptyExportTitle?: string;
|
||||
emptyExportDescription?: string;
|
||||
allScopeLabel?: ReactNode;
|
||||
filteredScopeLabel?: ReactNode;
|
||||
progressSubjectLabel?: string;
|
||||
};
|
||||
|
||||
// ─── Callbacks ───────────────────────────────────────────────────────
|
||||
export type DataGridCallbacks<TRow> = {
|
||||
onRowClick?: (row: TRow, rowId: RowId, event: React.MouseEvent) => void;
|
||||
@ -334,6 +366,9 @@ export type DataGridProps<TRow> = {
|
||||
|
||||
/** 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<TRow>;
|
||||
/** i18n overrides. */
|
||||
strings?: Partial<DataGridStrings>;
|
||||
|
||||
|
||||
@ -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',
|
||||
} : {},
|
||||
|
||||
@ -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<HasTokenStore extends boolean, Pro
|
||||
includeAnonymous?: boolean,
|
||||
onlyAnonymous?: boolean,
|
||||
teamId?: string,
|
||||
], UsersCrud['Server']['List']>(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<string[], UsersCrud['Server']['Read'] | null>(async ([userId]) => {
|
||||
const user = await this._interface.getServerUserById(userId);
|
||||
@ -1393,7 +1395,8 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
// END_PLATFORM
|
||||
|
||||
async listUsers(options?: ServerListUsersOptions): Promise<ServerUser[] & { nextCursor: string | null }> {
|
||||
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<HasTokenStore extends boolean, Pro
|
||||
|
||||
// IF_PLATFORM react-like
|
||||
useUsers(options?: ServerListUsersOptions): ServerUser[] & { nextCursor: string | null } {
|
||||
const crud = useAsyncCache(this._serverUsersCache, [options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous, options?.teamId] as const, "serverApp.useUsers()");
|
||||
const excludedEmailDomains = options?.excludedEmailDomains && options.excludedEmailDomains.length > 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;
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user