diff --git a/packages/frontend/admin/src/modules/accounts/components/columns.tsx b/packages/frontend/admin/src/modules/accounts/components/columns.tsx index 7ad64ff58c..83e3e36b48 100644 --- a/packages/frontend/admin/src/modules/accounts/components/columns.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/columns.tsx @@ -17,6 +17,8 @@ import { } from 'lucide-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'; const StatusItem = ({ @@ -54,9 +56,36 @@ const StatusItem = ({ export const columns: ColumnDef[] = [ { - accessorKey: 'info', + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), cell: ({ row }) => ( -
+ row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'info', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
@@ -102,6 +131,13 @@ export const columns: ColumnDef[] = [ }, { accessorKey: 'property', + header: ({ column }) => ( + + ), cell: ({ row: { original: user } }) => (
@@ -124,8 +160,18 @@ export const columns: ColumnDef[] = [ />
-
), }, + { + id: 'actions', + header: ({ column }) => ( + + ), + cell: ({ row: { original: user } }) => , + }, ]; diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-column-header.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-column-header.tsx new file mode 100644 index 0000000000..6eb96023fc --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-column-header.tsx @@ -0,0 +1,22 @@ +import type { Column } from '@tanstack/react-table'; + +import { cn } from '../../../utils'; + +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column; + title: string; +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
; + } + + // TODO(@Jimmfly): add sort + return
{title}
; +} diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx index d284dff40e..c55910256c 100644 --- a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx @@ -1,10 +1,11 @@ import { Button } from '@affine/admin/components/ui/button'; import { Input } from '@affine/admin/components/ui/input'; import { useQuery } from '@affine/admin/use-query'; -import { getUserByEmailQuery } from '@affine/graphql'; -import { PlusIcon } from 'lucide-react'; -import type { SetStateAction } from 'react'; +import { getUserByEmailQuery, type UserType } from '@affine/graphql'; +import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc'; +import type { Table } from '@tanstack/react-table'; import { + type SetStateAction, startTransition, useCallback, useEffect, @@ -14,11 +15,14 @@ import { import { useRightPanel } from '../../panel/context'; import { DiscardChanges } from './discard-changes'; +import { ExportUsersDialog } from './export-users-dialog'; +import { ImportUsersDialog } from './import-users-dialog'; import { CreateUserForm } from './user-form'; interface DataTableToolbarProps { data: TData[]; setDataTable: (data: TData[]) => void; + table?: Table; } const useSearch = () => { @@ -55,9 +59,12 @@ function useDebouncedValue(value: T, delay: number): T { export function DataTableToolbar({ data, setDataTable, + table, }: DataTableToolbarProps) { const [value, setValue] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); + const [exportDialogOpen, setExportDialogOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); const debouncedValue = useDebouncedValue(value, 1000); const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel(); const { result, query } = useSearch(); @@ -106,22 +113,82 @@ export function DataTableToolbar({ return handleConfirm(); }, [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 ( -
-
- +
+ + + + + {table && ( + row.original as UserType)} + open={exportDialogOpen} + onOpenChange={setExportDialogOpen} + /> + )} + +
- + +
+
+ +
+ +
+ ({ }: DataTableProps) { const usersCount = useUserCount(); + const [rowSelection, setRowSelection] = useState({}); + const [columnFilters, setColumnFilters] = useState([]); + const [tableData, setTableData] = useState(data); const table = useReactTable({ data: tableData, @@ -46,8 +54,13 @@ export function DataTable({ rowCount: usersCount, enableFilters: true, onPaginationChange: onPaginationChange, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onColumnFiltersChange: setColumnFilters, state: { pagination, + rowSelection, + columnFilters, }, }); @@ -57,24 +70,72 @@ export function DataTable({ return (
- - + +
+ + {table.getHeaderGroups().map(headerGroup => ( + + {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 ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map(row => ( - - {row.getVisibleCells().map(cell => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} + + {row.getVisibleCells().map(cell => { + let columnClassName = ''; + if (cell.column.id === 'select') { + columnClassName = 'w-[40px] flex-shrink-0'; + } else if (cell.column.id === 'info') { + columnClassName = 'flex-1'; + } else if (cell.column.id === 'property') { + columnClassName = 'flex-1'; + } else if (cell.column.id === 'actions') { + columnClassName = + 'w-[40px] flex-shrink-0 justify-center mr-6'; + } + + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} )) ) : ( @@ -89,7 +150,7 @@ export function DataTable({ )}
- +
diff --git a/packages/frontend/admin/src/modules/accounts/components/export-users-dialog.tsx b/packages/frontend/admin/src/modules/accounts/components/export-users-dialog.tsx new file mode 100644 index 0000000000..5db42721e9 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/export-users-dialog.tsx @@ -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([ + { + 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 ( + + + + Export + + +
+ {fields.map(field => ( +
+ + handleFieldChange(field.id, !!checked) + } + /> + +
+ ))} +
+ + + + + +
+
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/file-upload-area.tsx b/packages/frontend/admin/src/modules/accounts/components/file-upload-area.tsx new file mode 100644 index 0000000000..39bc87f612 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/file-upload-area.tsx @@ -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; +} + +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(null); + + const handleFileUpload = useAsyncCallback( + async (event: ChangeEvent) => { + 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 ( +
+
+ +
+ {isDragging + ? 'Release mouse to upload file' + : 'Click to upload CSV file'} +
+

+ {isDragging ? 'Preparing to upload...' : 'Or drag and drop file here'} +

+
+ +
+ ); +}); + +FileUploadArea.displayName = 'FileUploadArea'; diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx new file mode 100644 index 0000000000..cd2a924943 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx @@ -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([]); + const [isPreviewMode, setIsPreviewMode] = useState(false); + const [isFormatError, setIsFormatError] = useState(false); + const importUsers = useImportUsers(); + const fileUploadRef = useRef(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 => + 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 ( + + + + + {isFormatError + ? 'Incorrect import format' + : isPreviewMode + ? isImported + ? 'Import results' + : 'Confirm import' + : 'Import'} + + + + {isFormatError ? ( +
+

+ You need to import the accounts by importing a CSV file in the + correct format. Please download the CSV template. +

+
+ ) : isPreviewMode ? ( +
+

+ {parsedUsers.length} users detected from the CSV file. Please + confirm the user list below and import. +

+ +
+ ) : ( +
+

+ You need to import the accounts by importing a CSV file in the + correct format. Please download the CSV template. +

+ + +
+ )} + + + {isFormatError ? ( + <> +
+ CSV template +
+ + + ) : isPreviewMode ? ( + <> + + + + ) : ( + <> +
+ CSV template +
+ + + )} +
+
+
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts index 39a76b1482..fddf2e00fc 100644 --- a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts +++ b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts @@ -11,6 +11,9 @@ import { disableUserMutation, enableUserMutation, getUsersCountQuery, + type ImportUsersInput, + type ImportUsersMutation, + importUsersMutation, listUsersQuery, updateAccountFeaturesMutation, updateAccountMutation, @@ -18,7 +21,15 @@ import { import { useCallback, useMemo, useState } from 'react'; 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 = () => { const { @@ -221,3 +232,115 @@ export const useUserCount = () => { }); 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, + }; +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/user-table.tsx b/packages/frontend/admin/src/modules/accounts/components/user-table.tsx new file mode 100644 index 0000000000..a3fa2f09aa --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/user-table.tsx @@ -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 = ({ users }) => { + return ( +
+ + + + + + + + + + {users.map((user, index) => ( + + + + + + ))} + +
+ Username + + Email + + Status +
+ {user.name || '-'} + + {user.email} + + {user.importStatus === ImportStatus.Success ? ( + + + Success + + ) : user.importStatus === ImportStatus.Failed ? ( + + + Failed ({user.importError}) + + ) : user.importStatus === ImportStatus.Processing ? ( + + + Processing... + + ) : user.valid === false ? ( + + + Invalid ({user.error}) + + ) : ( + + + Valid + + )} +
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts b/packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts new file mode 100644 index 0000000000..949f379112 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts @@ -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(); + + 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(); + } +}; diff --git a/packages/frontend/graphql/src/graphql/import-users.gql b/packages/frontend/graphql/src/graphql/import-users.gql new file mode 100644 index 0000000000..deebdc38e9 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/import-users.gql @@ -0,0 +1,14 @@ +mutation ImportUsers($input: ImportUsersInput!) { + importUsers(input: $input) { + __typename + ... on UserType { + id + name + email + } + ... on UserImportFailedType { + email + error + } + } +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index ee179924c2..ec6ac50aed 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -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 = { id: 'getInvoicesCountQuery' as const, op: 'getInvoicesCount', diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 14b620d0ce..111702c098 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -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 GetInvoicesCountQuery = { @@ -4191,6 +4203,11 @@ export type Mutations = variables: GrantDocUserRolesMutationVariables; response: GrantDocUserRolesMutation; } + | { + name: 'importUsersMutation'; + variables: ImportUsersMutationVariables; + response: ImportUsersMutation; + } | { name: 'leaveWorkspaceMutation'; variables: LeaveWorkspaceMutationVariables;