mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat(admin): add import and export users to admin panel (#10810)
This commit is contained in:
@@ -17,6 +17,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Checkbox } from '../../../components/ui/checkbox';
|
||||||
|
import { DataTableColumnHeader } from './data-table-column-header';
|
||||||
import { DataTableRowActions } from './data-table-row-actions';
|
import { DataTableRowActions } from './data-table-row-actions';
|
||||||
|
|
||||||
const StatusItem = ({
|
const StatusItem = ({
|
||||||
@@ -54,9 +56,36 @@ const StatusItem = ({
|
|||||||
|
|
||||||
export const columns: ColumnDef<UserType>[] = [
|
export const columns: ColumnDef<UserType>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'info',
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||||
|
}
|
||||||
|
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Select all"
|
||||||
|
className="translate-y-[2px]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-3 items-center max-w-[50vw] overflow-hidden">
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={value => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Select row"
|
||||||
|
className="translate-y-[2px]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'info',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader className="text-xs" column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-4 items-center max-w-[50vw] overflow-hidden">
|
||||||
<Avatar className="w-10 h-10">
|
<Avatar className="w-10 h-10">
|
||||||
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
@@ -102,6 +131,13 @@ export const columns: ColumnDef<UserType>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'property',
|
accessorKey: 'property',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
className="text-xs max-md:hidden"
|
||||||
|
column={column}
|
||||||
|
title="UUID"
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row: { original: user } }) => (
|
cell: ({ row: { original: user } }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
||||||
@@ -124,8 +160,18 @@ export const columns: ColumnDef<UserType>[] = [
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTableRowActions user={user} />
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
className="text-xs"
|
||||||
|
column={column}
|
||||||
|
title="Actions"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row: { original: user } }) => <DataTableRowActions user={user} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Column } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { cn } from '../../../utils';
|
||||||
|
|
||||||
|
interface DataTableColumnHeaderProps<TData, TValue>
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
column: Column<TData, TValue>;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableColumnHeader<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <div className={cn(className)}>{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(@Jimmfly): add sort
|
||||||
|
return <div className={cn(className)}>{title}</div>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Button } from '@affine/admin/components/ui/button';
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
import { Input } from '@affine/admin/components/ui/input';
|
import { Input } from '@affine/admin/components/ui/input';
|
||||||
import { useQuery } from '@affine/admin/use-query';
|
import { useQuery } from '@affine/admin/use-query';
|
||||||
import { getUserByEmailQuery } from '@affine/graphql';
|
import { getUserByEmailQuery, type UserType } from '@affine/graphql';
|
||||||
import { PlusIcon } from 'lucide-react';
|
import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||||
import type { SetStateAction } from 'react';
|
import type { Table } from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
|
type SetStateAction,
|
||||||
startTransition,
|
startTransition,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -14,11 +15,14 @@ import {
|
|||||||
|
|
||||||
import { useRightPanel } from '../../panel/context';
|
import { useRightPanel } from '../../panel/context';
|
||||||
import { DiscardChanges } from './discard-changes';
|
import { DiscardChanges } from './discard-changes';
|
||||||
|
import { ExportUsersDialog } from './export-users-dialog';
|
||||||
|
import { ImportUsersDialog } from './import-users-dialog';
|
||||||
import { CreateUserForm } from './user-form';
|
import { CreateUserForm } from './user-form';
|
||||||
|
|
||||||
interface DataTableToolbarProps<TData> {
|
interface DataTableToolbarProps<TData> {
|
||||||
data: TData[];
|
data: TData[];
|
||||||
setDataTable: (data: TData[]) => void;
|
setDataTable: (data: TData[]) => void;
|
||||||
|
table?: Table<TData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSearch = () => {
|
const useSearch = () => {
|
||||||
@@ -55,9 +59,12 @@ function useDebouncedValue<T>(value: T, delay: number): T {
|
|||||||
export function DataTableToolbar<TData>({
|
export function DataTableToolbar<TData>({
|
||||||
data,
|
data,
|
||||||
setDataTable,
|
setDataTable,
|
||||||
|
table,
|
||||||
}: DataTableToolbarProps<TData>) {
|
}: DataTableToolbarProps<TData>) {
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const debouncedValue = useDebouncedValue(value, 1000);
|
const debouncedValue = useDebouncedValue(value, 1000);
|
||||||
const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel();
|
const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel();
|
||||||
const { result, query } = useSearch();
|
const { result, query } = useSearch();
|
||||||
@@ -106,22 +113,82 @@ export function DataTableToolbar<TData>({
|
|||||||
return handleConfirm();
|
return handleConfirm();
|
||||||
}, [handleConfirm, isOpen]);
|
}, [handleConfirm, isOpen]);
|
||||||
|
|
||||||
|
const handleExportUsers = useCallback(() => {
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||||
|
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
alert('Please select at least one user to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExportDialogOpen(true);
|
||||||
|
}, [table]);
|
||||||
|
|
||||||
|
const handleImportUsers = useCallback(() => {
|
||||||
|
setImportDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-y-2 gap-x-4">
|
||||||
<div className="flex flex-1 items-center space-x-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Input
|
<Button
|
||||||
placeholder="Search Email"
|
variant="outline"
|
||||||
value={value}
|
size="sm"
|
||||||
onChange={onValueChange}
|
className="h-8 px-2 lg:px-3"
|
||||||
className="h-10 w-full mr-[10px]"
|
onClick={handleImportUsers}
|
||||||
|
>
|
||||||
|
<ImportIcon fontSize={20} />
|
||||||
|
<span className="ml-2 hidden md:inline-block">Import</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 lg:px-3"
|
||||||
|
onClick={handleExportUsers}
|
||||||
|
disabled={
|
||||||
|
!table || table.getFilteredSelectedRowModel().rows.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExportIcon fontSize={20} />
|
||||||
|
<span className="ml-2 hidden md:inline-block">Export</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{table && (
|
||||||
|
<ExportUsersDialog
|
||||||
|
users={table
|
||||||
|
.getFilteredSelectedRowModel()
|
||||||
|
.rows.map(row => row.original as UserType)}
|
||||||
|
open={exportDialogOpen}
|
||||||
|
onOpenChange={setExportDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ImportUsersDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
onOpenChange={setImportDialogOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
className="px-4 py-2 space-x-[10px] text-sm font-medium"
|
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
||||||
onClick={handleOpenConfirm}
|
<div className="flex">
|
||||||
>
|
<Input
|
||||||
<PlusIcon size={20} /> <span>Add User</span>
|
placeholder="Search Email"
|
||||||
</Button>
|
value={value}
|
||||||
|
onChange={onValueChange}
|
||||||
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="h-8 px-2 lg:px-3 space-x-[6px] text-sm font-medium"
|
||||||
|
onClick={handleOpenConfirm}
|
||||||
|
>
|
||||||
|
<PlusIcon fontSize={20} /> <span>Add User</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DiscardChanges
|
<DiscardChanges
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@affine/admin/components/ui/table';
|
} from '@affine/admin/components/ui/table';
|
||||||
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
|
import type {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
PaginationState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -37,6 +42,9 @@ export function DataTable<TData, TValue>({
|
|||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const usersCount = useUserCount();
|
const usersCount = useUserCount();
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
const [tableData, setTableData] = useState(data);
|
const [tableData, setTableData] = useState(data);
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: tableData,
|
data: tableData,
|
||||||
@@ -46,8 +54,13 @@ export function DataTable<TData, TValue>({
|
|||||||
rowCount: usersCount,
|
rowCount: usersCount,
|
||||||
enableFilters: true,
|
enableFilters: true,
|
||||||
onPaginationChange: onPaginationChange,
|
onPaginationChange: onPaginationChange,
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
state: {
|
state: {
|
||||||
pagination,
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
columnFilters,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,24 +70,72 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-5 px-6 h-full">
|
<div className="flex flex-col gap-4 py-5 px-6 h-full">
|
||||||
<DataTableToolbar setDataTable={setTableData} data={data} />
|
<DataTableToolbar setDataTable={setTableData} data={data} table={table} />
|
||||||
<ScrollArea className="rounded-md border max-h-[75vh] h-full">
|
<div className="rounded-md border max-h-[75vh] h-full overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
|
<TableRow key={headerGroup.id} className="flex items-center">
|
||||||
|
{headerGroup.headers.map(header => {
|
||||||
|
let columnClassName = '';
|
||||||
|
if (header.id === 'select') {
|
||||||
|
columnClassName = 'w-[40px] flex-shrink-0';
|
||||||
|
} else if (header.id === 'info') {
|
||||||
|
columnClassName = 'flex-1';
|
||||||
|
} else if (header.id === 'property') {
|
||||||
|
columnClassName = 'flex-1';
|
||||||
|
} else if (header.id === 'actions') {
|
||||||
|
columnClassName =
|
||||||
|
'w-[40px] flex-shrink-0 justify-center mr-6';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
colSpan={header.colSpan}
|
||||||
|
className={`${columnClassName} py-2 text-xs flex items-center h-9`}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map(row => (
|
table.getRowModel().rows.map(row => (
|
||||||
<TableRow
|
<TableRow key={row.id} className="flex items-center">
|
||||||
key={row.id}
|
{row.getVisibleCells().map(cell => {
|
||||||
className="flex items-center justify-between"
|
let columnClassName = '';
|
||||||
>
|
if (cell.column.id === 'select') {
|
||||||
{row.getVisibleCells().map(cell => (
|
columnClassName = 'w-[40px] flex-shrink-0';
|
||||||
<TableCell key={cell.id}>
|
} else if (cell.column.id === 'info') {
|
||||||
{flexRender(
|
columnClassName = 'flex-1';
|
||||||
cell.column.columnDef.cell,
|
} else if (cell.column.id === 'property') {
|
||||||
cell.getContext()
|
columnClassName = 'flex-1';
|
||||||
)}
|
} else if (cell.column.id === 'actions') {
|
||||||
</TableCell>
|
columnClassName =
|
||||||
))}
|
'w-[40px] flex-shrink-0 justify-center mr-6';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={`${columnClassName} flex items-center`}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -89,7 +150,7 @@ export function DataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</div>
|
||||||
|
|
||||||
<DataTablePagination table={table} />
|
<DataTablePagination table={table} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
|
import { Checkbox } from '@affine/admin/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@affine/admin/components/ui/dialog';
|
||||||
|
import { Label } from '@affine/admin/components/ui/label';
|
||||||
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
|
import type { UserType } from '@affine/graphql';
|
||||||
|
import { CopyIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { type ExportField, useExportUsers } from './use-user-management';
|
||||||
|
|
||||||
|
interface ExportUsersDialogProps {
|
||||||
|
users: UserType[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportUsersDialog({
|
||||||
|
users,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ExportUsersDialogProps) {
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
const [fields, setFields] = useState<ExportField[]>([
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
label: 'Username',
|
||||||
|
checked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
checked: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(id: string, checked: boolean) => {
|
||||||
|
setFields(
|
||||||
|
fields.map(field => (field.id === id ? { ...field, checked } : field))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[fields]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { exportCSV, copyToClipboard } = useExportUsers();
|
||||||
|
|
||||||
|
const handleExport = useAsyncCallback(async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
await exportCSV(users, fields, () => {
|
||||||
|
setIsExporting(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
toast('Users exported successfully');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export users', error);
|
||||||
|
toast.error('Failed to export users');
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}, [exportCSV, fields, onOpenChange, users]);
|
||||||
|
|
||||||
|
const handleCopy = useAsyncCallback(async () => {
|
||||||
|
setIsCopying(true);
|
||||||
|
try {
|
||||||
|
await copyToClipboard(users, fields, () => {
|
||||||
|
setIsCopying(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
toast('Users copied successfully');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy users', error);
|
||||||
|
toast.error('Failed to copy users');
|
||||||
|
setIsCopying(false);
|
||||||
|
}
|
||||||
|
}, [copyToClipboard, fields, onOpenChange, users]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Export</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{fields.map(field => (
|
||||||
|
<div key={field.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`export-${field.id}`}
|
||||||
|
checked={field.checked}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
handleFieldChange(field.id, !!checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`export-${field.id}`}>{field.label}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
className="w-full text-[15px] px-4 py-2 h-10"
|
||||||
|
disabled={isExporting || isCopying}
|
||||||
|
>
|
||||||
|
{isExporting ? 'Exporting...' : 'Download account information'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="p-5"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={isExporting || isCopying}
|
||||||
|
>
|
||||||
|
<CopyIcon fontSize={20} />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
|
import { UploadIcon } from '@blocksuite/icons/rc';
|
||||||
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
type DragEvent,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface FileUploadAreaProps {
|
||||||
|
onFileSelected: (file: File) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadAreaRef {
|
||||||
|
triggerFileUpload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for CSV file upload with drag and drop support
|
||||||
|
*/
|
||||||
|
export const FileUploadArea = forwardRef<
|
||||||
|
FileUploadAreaRef,
|
||||||
|
FileUploadAreaProps
|
||||||
|
>(({ onFileSelected }, ref) => {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = useAsyncCallback(
|
||||||
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
await onFileSelected(file);
|
||||||
|
},
|
||||||
|
[onFileSelected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
triggerFileUpload: triggerFileInput,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validateAndProcessFile = useAsyncCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
|
||||||
|
toast.error('Please upload a CSV file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onFileSelected(file);
|
||||||
|
},
|
||||||
|
[onFileSelected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
validateAndProcessFile(files[0]);
|
||||||
|
},
|
||||||
|
[validateAndProcessFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex justify-center p-8 border-2 border-dashed rounded-md ${
|
||||||
|
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<UploadIcon fontSize={36} className="mx-auto mb-2 text-gray-400" />
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{isDragging
|
||||||
|
? 'Release mouse to upload file'
|
||||||
|
: 'Click to upload CSV file'}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{isDragging ? 'Preparing to upload...' : 'Or drag and drop file here'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FileUploadArea.displayName = 'FileUploadArea';
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@affine/admin/components/ui/dialog';
|
||||||
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
downloadCsvTemplate,
|
||||||
|
exportImportResults,
|
||||||
|
getValidUsersToImport,
|
||||||
|
ImportStatus,
|
||||||
|
type ParsedUser,
|
||||||
|
processCSVFile,
|
||||||
|
} from '../utils/csv-utils';
|
||||||
|
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
|
||||||
|
import {
|
||||||
|
useImportUsers,
|
||||||
|
type UserImportReturnType,
|
||||||
|
} from './use-user-management';
|
||||||
|
import { UserTable } from './user-table';
|
||||||
|
|
||||||
|
interface ImportUsersDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportUsersDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ImportUsersDialogProps) {
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
|
||||||
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||||
|
const [isFormatError, setIsFormatError] = useState(false);
|
||||||
|
const importUsers = useImportUsers();
|
||||||
|
const fileUploadRef = useRef<FileUploadAreaRef>(null);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(
|
||||||
|
() => fileUploadRef.current?.triggerFileUpload(),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset all states when dialog is closed
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setIsPreviewMode(false);
|
||||||
|
setParsedUsers([]);
|
||||||
|
setIsImporting(false);
|
||||||
|
setIsFormatError(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const importUsersCallback = useCallback(
|
||||||
|
(result: UserImportReturnType) => {
|
||||||
|
const successfulUsers = result.filter(
|
||||||
|
(user): user is Extract<typeof user, { __typename: 'UserType' }> =>
|
||||||
|
user.__typename === 'UserType'
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedUsers = result.filter(
|
||||||
|
(
|
||||||
|
user
|
||||||
|
): user is Extract<
|
||||||
|
typeof user,
|
||||||
|
{ __typename: 'UserImportFailedType' }
|
||||||
|
> => user.__typename === 'UserImportFailedType'
|
||||||
|
);
|
||||||
|
|
||||||
|
const successCount = successfulUsers.length;
|
||||||
|
const failedCount = parsedUsers.length - successCount;
|
||||||
|
|
||||||
|
if (failedCount > 0) {
|
||||||
|
toast.info(
|
||||||
|
`Successfully imported ${successCount} users, ${failedCount} failed`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(`Successfully imported ${successCount} users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const successfulUserEmails = new Set(
|
||||||
|
successfulUsers.map(user => user.email)
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedUserErrorMap = new Map(
|
||||||
|
failedUsers.map(user => [user.email, user.error])
|
||||||
|
);
|
||||||
|
|
||||||
|
setParsedUsers(prev => {
|
||||||
|
return prev.map(user => {
|
||||||
|
if (successfulUserEmails.has(user.email)) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
importStatus: ImportStatus.Success,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = failedUserErrorMap.get(user.email) || user.error;
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
importStatus: ImportStatus.Failed,
|
||||||
|
importError: errorMessage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsImporting(false);
|
||||||
|
},
|
||||||
|
[parsedUsers.length, setIsImporting]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileSelected = useCallback(async (file: File) => {
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
await processCSVFile(
|
||||||
|
file,
|
||||||
|
validatedUsers => {
|
||||||
|
setParsedUsers(validatedUsers);
|
||||||
|
setIsPreviewMode(true);
|
||||||
|
setIsImporting(false);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setIsImporting(false);
|
||||||
|
setIsFormatError(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process file', error);
|
||||||
|
setIsImporting(false);
|
||||||
|
setIsFormatError(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmImport = useAsyncCallback(async () => {
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const validUsersToImport = getValidUsersToImport(parsedUsers);
|
||||||
|
|
||||||
|
setParsedUsers(prev =>
|
||||||
|
prev.map(user =>
|
||||||
|
user.valid ? { ...user, importStatus: ImportStatus.Processing } : user
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await importUsers({ users: validUsersToImport }, importUsersCallback);
|
||||||
|
// Note: setIsImporting(false) is now handled in importUsersCallback
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import users', error);
|
||||||
|
toast.error('Failed to import users');
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
}, [importUsers, importUsersCallback, parsedUsers]);
|
||||||
|
|
||||||
|
const cancelImport = useCallback(() => {
|
||||||
|
setIsPreviewMode(false);
|
||||||
|
setParsedUsers([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetFormatError = useCallback(() => {
|
||||||
|
setIsFormatError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle closing the dialog after import is complete
|
||||||
|
const handleDone = useCallback(() => {
|
||||||
|
// Reset all states and close the dialog
|
||||||
|
setIsPreviewMode(false);
|
||||||
|
setParsedUsers([]);
|
||||||
|
setIsImporting(false);
|
||||||
|
setIsFormatError(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
// Export failed imports to CSV
|
||||||
|
const exportResult = useCallback(() => {
|
||||||
|
exportImportResults(parsedUsers);
|
||||||
|
}, [parsedUsers]);
|
||||||
|
|
||||||
|
const isImported = useMemo(() => {
|
||||||
|
return parsedUsers.some(
|
||||||
|
user => user.importStatus && user.importStatus !== ImportStatus.Processing
|
||||||
|
);
|
||||||
|
}, [parsedUsers]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (isImported) {
|
||||||
|
exportResult();
|
||||||
|
handleDone();
|
||||||
|
} else {
|
||||||
|
confirmImport();
|
||||||
|
}
|
||||||
|
}, [confirmImport, exportResult, handleDone, isImported]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className={isPreviewMode ? 'sm:max-w-[600px]' : 'sm:max-w-[425px]'}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isFormatError
|
||||||
|
? 'Incorrect import format'
|
||||||
|
: isPreviewMode
|
||||||
|
? isImported
|
||||||
|
? 'Import results'
|
||||||
|
: 'Confirm import'
|
||||||
|
: 'Import'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isFormatError ? (
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
You need to import the accounts by importing a CSV file in the
|
||||||
|
correct format. Please download the CSV template.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : isPreviewMode ? (
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{parsedUsers.length} users detected from the CSV file. Please
|
||||||
|
confirm the user list below and import.
|
||||||
|
</p>
|
||||||
|
<UserTable users={parsedUsers} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
You need to import the accounts by importing a CSV file in the
|
||||||
|
correct format. Please download the CSV template.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FileUploadArea
|
||||||
|
ref={fileUploadRef}
|
||||||
|
onFileSelected={handleFileSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter
|
||||||
|
className={`flex-col sm:flex-row sm:justify-between items-center ${isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'}`}
|
||||||
|
>
|
||||||
|
{isFormatError ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={downloadCsvTemplate}
|
||||||
|
className="mb-2 sm:mb-0 text-[15px] px-4 py-2 h-10 underline cursor-pointer"
|
||||||
|
>
|
||||||
|
CSV template
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={resetFormatError}
|
||||||
|
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : isPreviewMode ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelImport}
|
||||||
|
className="mb-2 sm:mb-0"
|
||||||
|
disabled={isImporting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||||
|
disabled={
|
||||||
|
isImporting ||
|
||||||
|
parsedUsers.some(
|
||||||
|
user => user.importStatus === ImportStatus.Processing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isImporting
|
||||||
|
? 'Importing...'
|
||||||
|
: isImported
|
||||||
|
? 'Export'
|
||||||
|
: 'Confirm Import'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={downloadCsvTemplate}
|
||||||
|
className="mb-2 sm:mb-0 underline text-[15px] cursor-pointer"
|
||||||
|
>
|
||||||
|
CSV template
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUpload}
|
||||||
|
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||||
|
disabled={isImporting}
|
||||||
|
>
|
||||||
|
{isImporting ? 'Parsing...' : 'Choose a file'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
disableUserMutation,
|
disableUserMutation,
|
||||||
enableUserMutation,
|
enableUserMutation,
|
||||||
getUsersCountQuery,
|
getUsersCountQuery,
|
||||||
|
type ImportUsersInput,
|
||||||
|
type ImportUsersMutation,
|
||||||
|
importUsersMutation,
|
||||||
listUsersQuery,
|
listUsersQuery,
|
||||||
updateAccountFeaturesMutation,
|
updateAccountFeaturesMutation,
|
||||||
updateAccountMutation,
|
updateAccountMutation,
|
||||||
@@ -18,7 +21,15 @@ import {
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { UserInput } from '../schema';
|
import type { UserInput, UserType } from '../schema';
|
||||||
|
|
||||||
|
export interface ExportField {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserImportReturnType = ImportUsersMutation['importUsers'];
|
||||||
|
|
||||||
export const useCreateUser = () => {
|
export const useCreateUser = () => {
|
||||||
const {
|
const {
|
||||||
@@ -221,3 +232,115 @@ export const useUserCount = () => {
|
|||||||
});
|
});
|
||||||
return usersCount;
|
return usersCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useImportUsers = () => {
|
||||||
|
const { trigger: importUsers } = useMutation({
|
||||||
|
mutation: importUsersMutation,
|
||||||
|
});
|
||||||
|
const revalidate = useMutateQueryResource();
|
||||||
|
|
||||||
|
const handleImportUsers = useCallback(
|
||||||
|
async (
|
||||||
|
input: ImportUsersInput,
|
||||||
|
callback?: (importUsers: UserImportReturnType) => void
|
||||||
|
) => {
|
||||||
|
await importUsers({ input })
|
||||||
|
.then(async ({ importUsers }) => {
|
||||||
|
await revalidate(listUsersQuery);
|
||||||
|
callback?.(importUsers);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
toast.error('Failed to import users: ' + e.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[importUsers, revalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return handleImportUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useExportUsers = () => {
|
||||||
|
const exportCSV = useCallback(
|
||||||
|
async (users: UserType[], fields: ExportField[], callback?: () => void) => {
|
||||||
|
const selectedFields = fields
|
||||||
|
.filter(field => field.checked)
|
||||||
|
.map(field => field.id);
|
||||||
|
|
||||||
|
if (selectedFields.length === 0) {
|
||||||
|
alert('Please select at least one field to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = selectedFields.map(
|
||||||
|
fieldId => fields.find(field => field.id === fieldId)?.label || fieldId
|
||||||
|
);
|
||||||
|
|
||||||
|
const csvRows = [headers.join(',')];
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const row = selectedFields.map(fieldId => {
|
||||||
|
const value = user[fieldId as keyof UserType];
|
||||||
|
|
||||||
|
return typeof value === 'string'
|
||||||
|
? `"${value.replace(/"/g, '""')}"`
|
||||||
|
: String(value);
|
||||||
|
});
|
||||||
|
csvRows.push(row.join(','));
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvContent = csvRows.join('\n');
|
||||||
|
|
||||||
|
// Add BOM (Byte Order Mark) to force Excel to interpret the file as UTF-8
|
||||||
|
const BOM = '\uFEFF';
|
||||||
|
const csvContentWithBOM = BOM + csvContent;
|
||||||
|
|
||||||
|
const blob = new Blob([csvContentWithBOM], {
|
||||||
|
type: 'text/csv;charset=utf-8;',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', 'exported_users.csv');
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
callback?.();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback(
|
||||||
|
async (users: UserType[], fields: ExportField[], callback?: () => void) => {
|
||||||
|
const selectedFields = fields
|
||||||
|
.filter(field => field.checked)
|
||||||
|
.map(field => field.id);
|
||||||
|
|
||||||
|
const dataToCopy: {
|
||||||
|
[key: string]: string;
|
||||||
|
}[] = [];
|
||||||
|
users.forEach(user => {
|
||||||
|
const row: { [key: string]: string } = {};
|
||||||
|
selectedFields.forEach(fieldId => {
|
||||||
|
const value = user[fieldId as keyof UserType];
|
||||||
|
row[fieldId] = typeof value === 'string' ? value : String(value);
|
||||||
|
});
|
||||||
|
dataToCopy.push(row);
|
||||||
|
});
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(dataToCopy, null, 2));
|
||||||
|
callback?.();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportCSV,
|
||||||
|
copyToClipboard,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ImportStatus, type ParsedUser } from '../utils/csv-utils';
|
||||||
|
|
||||||
|
interface UserTableProps {
|
||||||
|
users: ParsedUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a table of users with their import status
|
||||||
|
*/
|
||||||
|
export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||||
|
return (
|
||||||
|
<div className="max-h-[300px] overflow-y-auto border rounded-md">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Username
|
||||||
|
</th>
|
||||||
|
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{users.map((user, index) => (
|
||||||
|
<tr
|
||||||
|
key={`${user.email}-${index}`}
|
||||||
|
className={`${user.valid === false ? 'bg-red-50' : ''}
|
||||||
|
${user.importStatus === ImportStatus.Failed ? 'bg-red-50' : ''}
|
||||||
|
${user.importStatus === ImportStatus.Success ? 'bg-green-50' : ''}
|
||||||
|
${user.importStatus === ImportStatus.Processing ? 'bg-yellow-50' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="py-2 px-4 text-sm text-gray-900 truncate max-w-[150px]">
|
||||||
|
{user.name || '-'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-2 px-4 text-sm truncate max-w-[200px] ${user.valid === false ? 'text-red-500' : 'text-gray-900'}`}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-4 text-sm">
|
||||||
|
{user.importStatus === ImportStatus.Success ? (
|
||||||
|
<span className="text-gray-900">
|
||||||
|
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
|
||||||
|
Success
|
||||||
|
</span>
|
||||||
|
) : user.importStatus === ImportStatus.Failed ? (
|
||||||
|
<span className="text-red-500" title={user.importError}>
|
||||||
|
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
|
||||||
|
Failed ({user.importError})
|
||||||
|
</span>
|
||||||
|
) : user.importStatus === ImportStatus.Processing ? (
|
||||||
|
<span className="text-yellow-500">
|
||||||
|
<span className="h-2 w-2 bg-yellow-500 rounded-full inline-block mr-2" />
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
) : user.valid === false ? (
|
||||||
|
<span className="text-red-500" title={user.error}>
|
||||||
|
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
|
||||||
|
Invalid ({user.error})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-900">
|
||||||
|
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
|
||||||
|
Valid
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
166
packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts
Normal file
166
packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { emailRegex } from '../../../utils';
|
||||||
|
|
||||||
|
export interface ParsedUser {
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
valid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
importStatus?: ImportStatus;
|
||||||
|
importError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ImportStatus {
|
||||||
|
Success = 'success',
|
||||||
|
Failed = 'failed',
|
||||||
|
Processing = 'processing',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates email addresses for duplicates and format
|
||||||
|
*/
|
||||||
|
export const validateEmails = (users: ParsedUser[]): ParsedUser[] => {
|
||||||
|
const emailMap = new Map<string, number>();
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const lowerCaseEmail = user.email.toLowerCase();
|
||||||
|
emailMap.set(lowerCaseEmail, (emailMap.get(lowerCaseEmail) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map(user => {
|
||||||
|
const lowerCaseEmail = user.email.toLowerCase();
|
||||||
|
|
||||||
|
if (!emailRegex.test(user.email)) {
|
||||||
|
return { ...user, valid: false, error: 'Invalid email format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailCount = emailMap.get(lowerCaseEmail) || 0;
|
||||||
|
if (emailCount > 1) {
|
||||||
|
return { ...user, valid: false, error: 'Duplicate email address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...user, valid: true };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters valid users for import
|
||||||
|
*/
|
||||||
|
export const getValidUsersToImport = (users: ParsedUser[]) => {
|
||||||
|
return users
|
||||||
|
.filter(user => user.valid === true)
|
||||||
|
.map(user => ({
|
||||||
|
name: user.name || undefined,
|
||||||
|
email: user.email,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a CSV template for user import
|
||||||
|
*/
|
||||||
|
export const downloadCsvTemplate = () => {
|
||||||
|
const csvContent = 'Username,Email\n,example@example.com';
|
||||||
|
downloadCsv(csvContent, 'user_import_template.csv');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports failed imports to a CSV file
|
||||||
|
*/
|
||||||
|
export const exportImportResults = (results: ParsedUser[]) => {
|
||||||
|
const csvContent = [
|
||||||
|
'Username,Email,status',
|
||||||
|
...results.map(
|
||||||
|
user =>
|
||||||
|
`${user.name || ''},${user.email},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Create and download the file
|
||||||
|
downloadCsv(
|
||||||
|
csvContent,
|
||||||
|
`import_results_${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(`Exported ${results.length} import results`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function for downloading CSV content with proper UTF-8 encoding for international characters
|
||||||
|
*/
|
||||||
|
export const downloadCsv = (csvContent: string, filename: string) => {
|
||||||
|
// Add BOM (Byte Order Mark) to force Excel to interpret the file as UTF-8
|
||||||
|
const BOM = '\uFEFF';
|
||||||
|
const csvContentWithBOM = BOM + csvContent;
|
||||||
|
|
||||||
|
const blob = new Blob([csvContentWithBOM], {
|
||||||
|
type: 'text/csv;charset=utf-8;',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a CSV file to extract user data
|
||||||
|
*/
|
||||||
|
export const processCSVFile = async (
|
||||||
|
file: File,
|
||||||
|
onSuccess: (users: ParsedUser[]) => void,
|
||||||
|
onError: () => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const csvContent = await file.text();
|
||||||
|
const rows = csvContent
|
||||||
|
.split('\n')
|
||||||
|
.filter(row => row.trim() !== '')
|
||||||
|
.map(row => row.split(','));
|
||||||
|
|
||||||
|
if (rows.length < 2) {
|
||||||
|
toast.error('CSV file format is incorrect or empty');
|
||||||
|
onError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRows = rows.slice(1);
|
||||||
|
|
||||||
|
const users = dataRows.map(row => ({
|
||||||
|
name: row[0]?.trim() || null,
|
||||||
|
email: row[1]?.trim() || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const usersWithEmail = users.filter(user => user.email);
|
||||||
|
|
||||||
|
if (usersWithEmail.length === 0) {
|
||||||
|
toast.error('CSV file contains no valid user data');
|
||||||
|
onError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedUsers = validateEmails(usersWithEmail);
|
||||||
|
const hasValidUsers = validatedUsers.some(user => user.valid !== false);
|
||||||
|
|
||||||
|
if (!hasValidUsers) {
|
||||||
|
toast.error('CSV file contains no valid user data');
|
||||||
|
onError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess(validatedUsers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse CSV file', error);
|
||||||
|
toast.error('Failed to parse CSV file');
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
};
|
||||||
14
packages/frontend/graphql/src/graphql/import-users.gql
Normal file
14
packages/frontend/graphql/src/graphql/import-users.gql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
mutation ImportUsers($input: ImportUsersInput!) {
|
||||||
|
importUsers(input: $input) {
|
||||||
|
__typename
|
||||||
|
... on UserType {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
}
|
||||||
|
... on UserImportFailedType {
|
||||||
|
email
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -915,6 +915,25 @@ export const listHistoryQuery = {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const importUsersMutation = {
|
||||||
|
id: 'importUsersMutation' as const,
|
||||||
|
op: 'ImportUsers',
|
||||||
|
query: `mutation ImportUsers($input: ImportUsersInput!) {
|
||||||
|
importUsers(input: $input) {
|
||||||
|
__typename
|
||||||
|
... on UserType {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
}
|
||||||
|
... on UserImportFailedType {
|
||||||
|
email
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const getInvoicesCountQuery = {
|
export const getInvoicesCountQuery = {
|
||||||
id: 'getInvoicesCountQuery' as const,
|
id: 'getInvoicesCountQuery' as const,
|
||||||
op: 'getInvoicesCount',
|
op: 'getInvoicesCount',
|
||||||
|
|||||||
@@ -3125,6 +3125,18 @@ export type ListHistoryQuery = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImportUsersMutationVariables = Exact<{
|
||||||
|
input: ImportUsersInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ImportUsersMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
importUsers: Array<
|
||||||
|
| { __typename: 'UserImportFailedType'; email: string; error: string }
|
||||||
|
| { __typename: 'UserType'; id: string; name: string; email: string }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetInvoicesCountQueryVariables = Exact<{ [key: string]: never }>;
|
export type GetInvoicesCountQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type GetInvoicesCountQuery = {
|
export type GetInvoicesCountQuery = {
|
||||||
@@ -4191,6 +4203,11 @@ export type Mutations =
|
|||||||
variables: GrantDocUserRolesMutationVariables;
|
variables: GrantDocUserRolesMutationVariables;
|
||||||
response: GrantDocUserRolesMutation;
|
response: GrantDocUserRolesMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'importUsersMutation';
|
||||||
|
variables: ImportUsersMutationVariables;
|
||||||
|
response: ImportUsersMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'leaveWorkspaceMutation';
|
name: 'leaveWorkspaceMutation';
|
||||||
variables: LeaveWorkspaceMutationVariables;
|
variables: LeaveWorkspaceMutationVariables;
|
||||||
|
|||||||
Reference in New Issue
Block a user