mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-06 21:02:52 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
"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 stackAdminApp = 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(
|
|
stackAdminApp,
|
|
scope === "filtered" ? exportOptions : undefined
|
|
);
|
|
|
|
if (allUsers.length === 0) {
|
|
toast({
|
|
title: "No users to export",
|
|
description: "There are no users matching the current filters",
|
|
variant: "destructive",
|
|
});
|
|
setIsExporting(false);
|
|
return;
|
|
}
|
|
|
|
// Transform user data based on selected fields
|
|
const transformedData = allUsers.map((user) =>
|
|
transformUserData(user, enabledFields)
|
|
);
|
|
|
|
// Export based on format
|
|
if (format === "csv") {
|
|
exportToCsv(transformedData);
|
|
} else {
|
|
exportToJson(transformedData);
|
|
}
|
|
|
|
toast({
|
|
title: "Export successful",
|
|
description: `Exported ${allUsers.length} user${allUsers.length === 1 ? "" : "s"}`,
|
|
variant: "success",
|
|
});
|
|
|
|
setOpen(false);
|
|
} catch (error) {
|
|
console.error("Export failed:", error);
|
|
toast({
|
|
title: "Export failed",
|
|
description: error instanceof Error ? error.message : "An unknown error occurred",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<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(
|
|
stackAdminApp: 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 stackAdminApp.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 stackAdminApp.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);
|
|
}
|