mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 14:27:02 +08:00
feat(admin): create user with password (#12112)
close AF-2494 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added support for importing users with optional passwords via CSV, including password validation and error handling. - Enhanced user creation form to allow password input and validation according to server-configured requirements. - User table now displays a password column and provides granular error highlighting for email and password fields. - Introduced detailed CSV format guidance and improved import dialog with dynamic content and footer controls. - **Improvements** - Updated import dialog workflow for clearer CSV formatting guidance and improved user feedback during import. - Refined dropdown menu actions and improved UI clarity for user management actions. - **Bug Fixes** - Corrected menu action handlers for "Edit" and "Reset Password" in user actions dropdown. - **Chores** - Refactored import dialog code into modular components for maintainability and clarity. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -170,24 +170,27 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon fontSize={20} /> Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
>
|
||||
<EditIcon fontSize={20} /> Edit
|
||||
<EditIcon fontSize={20} />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon fontSize={20} />
|
||||
{user.hasPassword ? 'Reset Password' : 'Setup Account'}
|
||||
</DropdownMenuItem>
|
||||
{user.disabled && (
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openEnableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} /> Enable Email
|
||||
<AccountBanIcon fontSize={20} />
|
||||
Enable Email
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -196,14 +199,16 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDisableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} /> Disable & Delete data
|
||||
<AccountBanIcon fontSize={20} />
|
||||
Disable & Delete data
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDeleteDialog}
|
||||
>
|
||||
<DeleteIcon fontSize={20} /> Delete
|
||||
<DeleteIcon fontSize={20} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useRightPanel } from '../../panel/context';
|
||||
import type { UserType } from '../schema';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { ExportUsersDialog } from './export-users-dialog';
|
||||
import { ImportUsersDialog } from './import-users-dialog';
|
||||
import { ImportUsersDialog } from './import-users';
|
||||
import { CreateUserForm } from './user-form';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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] flex-col' : 'sm:max-w-[425px]'
|
||||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isFormatError
|
||||
? 'Incorrect import format'
|
||||
: isPreviewMode
|
||||
? isImported
|
||||
? 'Import results'
|
||||
: 'Confirm import'
|
||||
: 'Import'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-[15px] mt-3">
|
||||
{isFormatError ? (
|
||||
'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.'
|
||||
) : isPreviewMode ? (
|
||||
<div className="grid gap-3">
|
||||
{isImported ? null : (
|
||||
<p>
|
||||
{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-3">
|
||||
<p>
|
||||
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>
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
<DialogFooter
|
||||
className={`flex-col mt-6 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-0 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"
|
||||
onClick={cancelImport}
|
||||
className="w-full mb-2 sm:mb-0 sm:w-auto"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { WarningIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import type { FC } from 'react';
|
||||
|
||||
interface CsvFormatGuidanceProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays CSV format guidelines
|
||||
*/
|
||||
export const CsvFormatGuidance: FC<CsvFormatGuidanceProps> = ({
|
||||
passwordLimits,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex p-1.5 gap-1"
|
||||
style={{
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center py-0.5">
|
||||
<WarningIcon fontSize={16} color={cssVarV2('icon/primary')} />
|
||||
</div>
|
||||
<div>
|
||||
<p>CSV file includes username, email, and password.</p>
|
||||
<ul>
|
||||
{[
|
||||
`Username (optional): any text.`,
|
||||
`Email (required): e.g., user@example.com.`,
|
||||
`Password (optional): ${passwordLimits.minLength}–${passwordLimits.maxLength} characters.`,
|
||||
].map((text, index) => (
|
||||
<li
|
||||
key={`guidance-${index}`}
|
||||
className="relative pl-2 leading-normal"
|
||||
>
|
||||
<span className="absolute left-0 top-2 w-1 h-1 rounded-full bg-current" />
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FC, RefObject } from 'react';
|
||||
|
||||
import type { ParsedUser } from '../../utils/csv-utils';
|
||||
import { UserTable } from '../user-table';
|
||||
import { CsvFormatGuidance } from './csv-format-guidance';
|
||||
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
|
||||
|
||||
interface ImportPreviewContentProps {
|
||||
parsedUsers: ParsedUser[];
|
||||
isImported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the preview mode content
|
||||
*/
|
||||
export const ImportPreviewContent: FC<ImportPreviewContentProps> = ({
|
||||
parsedUsers,
|
||||
isImported,
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{!isImported && (
|
||||
<p>
|
||||
{parsedUsers.length} users detected from the CSV file. Please confirm
|
||||
the user list below and import.
|
||||
</p>
|
||||
)}
|
||||
<UserTable users={parsedUsers} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportInitialContentProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
fileUploadRef: RefObject<FileUploadAreaRef | null>;
|
||||
onFileSelected: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the initial import screen
|
||||
*/
|
||||
export const ImportInitialContent: FC<ImportInitialContentProps> = ({
|
||||
passwordLimits,
|
||||
fileUploadRef,
|
||||
onFileSelected,
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<p>
|
||||
You need to import the accounts by importing a CSV file in the correct
|
||||
format. Please download the CSV template.
|
||||
</p>
|
||||
<CsvFormatGuidance passwordLimits={passwordLimits} />
|
||||
<FileUploadArea ref={fileUploadRef} onFileSelected={onFileSelected} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportErrorContentProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying import errors
|
||||
*/
|
||||
export const ImportErrorContent: FC<ImportErrorContentProps> = ({
|
||||
message = 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.',
|
||||
}) => {
|
||||
return message;
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { DialogFooter } from '@affine/admin/components/ui/dialog';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { downloadCsvTemplate, ImportStatus } from '../../utils/csv-utils';
|
||||
|
||||
interface ImportUsersFooterProps {
|
||||
isFormatError: boolean;
|
||||
isPreviewMode: boolean;
|
||||
isImporting: boolean;
|
||||
isImported: boolean;
|
||||
resetFormatError: () => void;
|
||||
cancelImport: () => void;
|
||||
handleConfirm: () => void;
|
||||
handleUpload: () => void;
|
||||
parsedUsers: {
|
||||
importStatus?: ImportStatus;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the dialog footer with appropriate buttons based on the import state
|
||||
*/
|
||||
export const ImportUsersFooter: FC<ImportUsersFooterProps> = ({
|
||||
isFormatError,
|
||||
isPreviewMode,
|
||||
isImporting,
|
||||
isImported,
|
||||
resetFormatError,
|
||||
cancelImport,
|
||||
handleConfirm,
|
||||
handleUpload,
|
||||
parsedUsers,
|
||||
}) => {
|
||||
return (
|
||||
<DialogFooter
|
||||
className={`flex-col mt-6 sm:flex-row sm:justify-between items-center ${
|
||||
isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'
|
||||
}`}
|
||||
>
|
||||
{isFormatError ? (
|
||||
<FormatErrorFooter
|
||||
downloadCsvTemplate={downloadCsvTemplate}
|
||||
resetFormatError={resetFormatError}
|
||||
/>
|
||||
) : isPreviewMode ? (
|
||||
<PreviewModeFooter
|
||||
isImporting={isImporting}
|
||||
isImported={isImported}
|
||||
cancelImport={cancelImport}
|
||||
handleConfirm={handleConfirm}
|
||||
parsedUsers={parsedUsers}
|
||||
/>
|
||||
) : (
|
||||
<InitialFooter
|
||||
isImporting={isImporting}
|
||||
downloadCsvTemplate={downloadCsvTemplate}
|
||||
handleUpload={handleUpload}
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormatErrorFooterProps {
|
||||
downloadCsvTemplate: () => void;
|
||||
resetFormatError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component when there's a format error
|
||||
*/
|
||||
const FormatErrorFooter: FC<FormatErrorFooterProps> = ({
|
||||
downloadCsvTemplate,
|
||||
resetFormatError,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
onClick={downloadCsvTemplate}
|
||||
className="mb-2 sm:mb-0 text-[15px] px-0 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>
|
||||
</>
|
||||
);
|
||||
|
||||
interface PreviewModeFooterProps {
|
||||
isImporting: boolean;
|
||||
isImported: boolean;
|
||||
cancelImport: () => void;
|
||||
handleConfirm: () => void;
|
||||
parsedUsers: {
|
||||
importStatus?: ImportStatus;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for preview mode
|
||||
*/
|
||||
const PreviewModeFooter: FC<PreviewModeFooterProps> = ({
|
||||
isImporting,
|
||||
isImported,
|
||||
cancelImport,
|
||||
handleConfirm,
|
||||
parsedUsers,
|
||||
}) => (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={cancelImport}
|
||||
className="w-full mb-2 sm:mb-0 sm:w-auto"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
||||
interface InitialFooterProps {
|
||||
isImporting: boolean;
|
||||
downloadCsvTemplate: () => void;
|
||||
handleUpload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for initial state
|
||||
*/
|
||||
const InitialFooter: FC<InitialFooterProps> = ({
|
||||
isImporting,
|
||||
downloadCsvTemplate,
|
||||
handleUpload,
|
||||
}) => (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useServerConfig } from '../../../common';
|
||||
import type { FileUploadAreaRef } from './file-upload-area';
|
||||
import {
|
||||
ImportErrorContent,
|
||||
ImportInitialContent,
|
||||
ImportPreviewContent,
|
||||
} from './import-content';
|
||||
import { ImportUsersFooter } from './import-footer';
|
||||
import { useImportUsersState } from './use-import-users-state';
|
||||
|
||||
export interface ImportUsersDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for importing users from a CSV file
|
||||
*/
|
||||
export function ImportUsersDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportUsersDialogProps) {
|
||||
const fileUploadRef = useRef<FileUploadAreaRef>(null);
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
|
||||
const handleUpload = () => fileUploadRef.current?.triggerFileUpload();
|
||||
|
||||
const {
|
||||
isImporting,
|
||||
parsedUsers,
|
||||
isPreviewMode,
|
||||
isFormatError,
|
||||
isImported,
|
||||
handleFileSelected,
|
||||
cancelImport,
|
||||
resetFormatError,
|
||||
handleConfirm,
|
||||
resetState,
|
||||
} = useImportUsersState({
|
||||
passwordLimits,
|
||||
onClose: () => onOpenChange(false),
|
||||
});
|
||||
|
||||
// Reset all states when dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState();
|
||||
}
|
||||
}, [open, resetState]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={
|
||||
isPreviewMode ? 'sm:max-w-[720px] flex-col' : 'sm:max-w-[480px]'
|
||||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isFormatError
|
||||
? 'Incorrect import format'
|
||||
: isPreviewMode
|
||||
? isImported
|
||||
? 'Import results'
|
||||
: 'Confirm import'
|
||||
: 'Import'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[15px] mt-3">
|
||||
{isFormatError ? (
|
||||
<ImportErrorContent />
|
||||
) : isPreviewMode ? (
|
||||
<ImportPreviewContent
|
||||
parsedUsers={parsedUsers}
|
||||
isImported={isImported}
|
||||
/>
|
||||
) : (
|
||||
<ImportInitialContent
|
||||
passwordLimits={passwordLimits}
|
||||
fileUploadRef={fileUploadRef}
|
||||
onFileSelected={handleFileSelected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImportUsersFooter
|
||||
isFormatError={isFormatError}
|
||||
isPreviewMode={isPreviewMode}
|
||||
isImporting={isImporting}
|
||||
isImported={isImported}
|
||||
resetFormatError={resetFormatError}
|
||||
cancelImport={cancelImport}
|
||||
handleConfirm={handleConfirm}
|
||||
handleUpload={handleUpload}
|
||||
parsedUsers={parsedUsers}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
exportImportResults,
|
||||
getValidUsersToImport,
|
||||
ImportStatus,
|
||||
type ParsedUser,
|
||||
processCSVFile,
|
||||
validateUsers,
|
||||
} from '../../utils/csv-utils';
|
||||
import {
|
||||
useImportUsers,
|
||||
type UserImportReturnType,
|
||||
} from '../use-user-management';
|
||||
|
||||
export interface ImportUsersStateProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useImportUsersState({
|
||||
passwordLimits,
|
||||
onClose,
|
||||
}: ImportUsersStateProps) {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [isFormatError, setIsFormatError] = useState(false);
|
||||
const importUsers = useImportUsers();
|
||||
|
||||
// Reset all states when dialog is closed
|
||||
const resetState = useCallback(() => {
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(false);
|
||||
}, []);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const handleFileSelected = useCallback(
|
||||
async (file: File) => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
await processCSVFile(
|
||||
file,
|
||||
usersFromCsv => {
|
||||
const validatedUsers = validateUsers(usersFromCsv, passwordLimits);
|
||||
setParsedUsers(validatedUsers);
|
||||
setIsPreviewMode(true);
|
||||
setIsImporting(false);
|
||||
},
|
||||
() => {
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to process file', error);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
},
|
||||
[passwordLimits]
|
||||
);
|
||||
|
||||
const confirmImport = useAsyncCallback(async () => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const validUsersToImport = getValidUsersToImport(parsedUsers);
|
||||
|
||||
setParsedUsers(prev =>
|
||||
prev.map(user => ({
|
||||
...user,
|
||||
importStatus: ImportStatus.Processing,
|
||||
}))
|
||||
);
|
||||
|
||||
await importUsers({ users: validUsersToImport }, 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(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [onClose, resetState]);
|
||||
|
||||
// Export failed imports to CSV
|
||||
const exportResult = useCallback(() => {
|
||||
exportImportResults(parsedUsers);
|
||||
}, [parsedUsers]);
|
||||
|
||||
const isImported = !!parsedUsers.some(
|
||||
user => user.importStatus && user.importStatus !== ImportStatus.Processing
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (isImported) {
|
||||
exportResult();
|
||||
handleDone();
|
||||
} else {
|
||||
confirmImport();
|
||||
}
|
||||
}, [confirmImport, exportResult, handleDone, isImported]);
|
||||
|
||||
return {
|
||||
isImporting,
|
||||
parsedUsers,
|
||||
isPreviewMode,
|
||||
isFormatError,
|
||||
isImported,
|
||||
handleFileSelected,
|
||||
cancelImport,
|
||||
resetFormatError,
|
||||
handleConfirm,
|
||||
resetState,
|
||||
};
|
||||
}
|
||||
@@ -47,12 +47,13 @@ export const useCreateUser = () => {
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const create = useAsyncCallback(
|
||||
async ({ name, email, features }: UserInput) => {
|
||||
async ({ name, email, password, features }: UserInput) => {
|
||||
try {
|
||||
const account = await createAccount({
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
password: password === '' ? undefined : password,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@ import { Label } from '@affine/admin/components/ui/label';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useServerConfig } from '../../common';
|
||||
import { RightPanelHeader } from '../../header';
|
||||
import type { UserInput, UserType } from '../schema';
|
||||
import { validateEmails, validatePassword } from '../utils/csv-utils';
|
||||
import { useCreateUser, useUpdateUser } from './use-user-management';
|
||||
|
||||
type UserFormProps = {
|
||||
@@ -20,6 +23,7 @@ type UserFormProps = {
|
||||
onConfirm: (user: UserInput) => void;
|
||||
onValidate: (user: Partial<UserInput>) => boolean;
|
||||
actions?: React.ReactNode;
|
||||
showOption?: boolean;
|
||||
};
|
||||
|
||||
function UserForm({
|
||||
@@ -29,6 +33,7 @@ function UserForm({
|
||||
onConfirm,
|
||||
onValidate,
|
||||
actions,
|
||||
showOption,
|
||||
}: UserFormProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
@@ -36,9 +41,10 @@ function UserForm({
|
||||
() => ({
|
||||
name: defaultValue?.name ?? '',
|
||||
email: defaultValue?.email ?? '',
|
||||
password: defaultValue?.password ?? '',
|
||||
features: defaultValue?.features ?? [],
|
||||
}),
|
||||
[defaultValue?.email, defaultValue?.features, defaultValue?.name]
|
||||
[defaultValue]
|
||||
);
|
||||
|
||||
const [changes, setChanges] = useState<Partial<UserInput>>(defaultUser);
|
||||
@@ -68,7 +74,8 @@ function UserForm({
|
||||
|
||||
// @ts-expect-error checked
|
||||
onConfirm(changes);
|
||||
}, [canSave, changes, onConfirm]);
|
||||
setChanges(defaultUser);
|
||||
}, [canSave, changes, defaultUser, onConfirm]);
|
||||
|
||||
const onFeatureChanged = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
@@ -116,6 +123,19 @@ function UserForm({
|
||||
onChange={setField}
|
||||
placeholder="Enter email address"
|
||||
/>
|
||||
{showOption && (
|
||||
<>
|
||||
<Separator />
|
||||
<InputItem
|
||||
label="Password"
|
||||
field="password"
|
||||
value={changes.password}
|
||||
onChange={setField}
|
||||
optional
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
@@ -167,12 +187,14 @@ function ToggleItem({
|
||||
function InputItem({
|
||||
label,
|
||||
field,
|
||||
optional,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
field: keyof UserInput;
|
||||
optional?: boolean;
|
||||
value?: string;
|
||||
onChange: (field: keyof UserInput, value: string) => void;
|
||||
placeholder?: string;
|
||||
@@ -185,9 +207,20 @@ function InputItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<Label className="text-[15px] leading-6 font-medium mb-1.5">
|
||||
<div className="flex flex-col gap-1.5 p-3">
|
||||
<Label
|
||||
className="text-[15px] font-medium flex-wrap flex"
|
||||
style={{ lineHeight: '1.6rem' }}
|
||||
>
|
||||
{label}
|
||||
{optional && (
|
||||
<span
|
||||
className="font-normal ml-1"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -210,6 +243,8 @@ const validateUpdateUser = (user: Partial<UserInput>) => {
|
||||
|
||||
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
const { create, creating } = useCreateUser();
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
useEffect(() => {
|
||||
if (creating) {
|
||||
return () => {
|
||||
@@ -219,12 +254,30 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
|
||||
return;
|
||||
}, [creating, onComplete]);
|
||||
|
||||
const handleCreateUser = useCallback(
|
||||
(user: UserInput) => {
|
||||
const emailValidation = validateEmails([user]);
|
||||
const passwordValidation = validatePassword(
|
||||
user.password,
|
||||
passwordLimits
|
||||
);
|
||||
if (!passwordValidation.valid || !emailValidation[0].valid) {
|
||||
toast.error(passwordValidation.error || emailValidation[0].error);
|
||||
return;
|
||||
}
|
||||
create(user);
|
||||
},
|
||||
[create, passwordLimits]
|
||||
);
|
||||
|
||||
return (
|
||||
<UserForm
|
||||
title="Create User"
|
||||
onClose={onComplete}
|
||||
onConfirm={create}
|
||||
onConfirm={handleCreateUser}
|
||||
onValidate={validateCreateUser}
|
||||
showOption={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ 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">
|
||||
<thead className="bg-white 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
|
||||
@@ -19,6 +19,9 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||
<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">
|
||||
Password
|
||||
</th>
|
||||
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
@@ -37,10 +40,26 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||
{user.name || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 px-4 text-sm truncate max-w-[200px] ${user.valid === false ? 'text-red-500' : 'text-gray-900'}`}
|
||||
className={`py-2 px-4 text-sm truncate max-w-[200px] ${
|
||||
user.valid === false &&
|
||||
(user.error?.toLowerCase().includes('email') ||
|
||||
!user.error?.toLowerCase().includes('password'))
|
||||
? 'text-red-500'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{user.email}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 px-4 text-sm truncate max-w-[150px] ${
|
||||
user.valid === false &&
|
||||
user.error?.toLowerCase().includes('password')
|
||||
? 'text-red-500'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{user.password || '-'}
|
||||
</td>
|
||||
<td className="py-2 px-4 text-sm">
|
||||
{user.importStatus === ImportStatus.Success ? (
|
||||
<span className="text-gray-900">
|
||||
|
||||
@@ -4,5 +4,6 @@ export type UserType = ListUsersQuery['users'][0];
|
||||
export type UserInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
features: FeatureType[];
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { emailRegex } from '../../../utils';
|
||||
export interface ParsedUser {
|
||||
name: string | null;
|
||||
email: string;
|
||||
password?: string;
|
||||
valid?: boolean;
|
||||
error?: string;
|
||||
importStatus?: ImportStatus;
|
||||
@@ -17,6 +18,41 @@ export enum ImportStatus {
|
||||
Processing = 'processing',
|
||||
}
|
||||
|
||||
export const validatePassword = (
|
||||
password: string | undefined,
|
||||
passwordLimits: { minLength: number; maxLength: number }
|
||||
): { valid: boolean; error?: string } => {
|
||||
// if password is empty, it is valid
|
||||
if (!password || password.trim() === '') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// check password length
|
||||
if (
|
||||
password.length < passwordLimits.minLength ||
|
||||
password.length > passwordLimits.maxLength
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid password format',
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(@Jimmfly): check password contains at least one letter and one number
|
||||
|
||||
// const hasLetter = /[a-zA-Z]/.test(password);
|
||||
// const hasNumber = /[0-9]/.test(password);
|
||||
|
||||
// if (!hasLetter || !hasNumber) {
|
||||
// return {
|
||||
// valid: false,
|
||||
// error: 'Invalid password format',
|
||||
// };
|
||||
// }
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates email addresses for duplicates and format
|
||||
*/
|
||||
@@ -53,6 +89,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
|
||||
.map(user => ({
|
||||
name: user.name || undefined,
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -60,7 +97,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
|
||||
* Downloads a CSV template for user import
|
||||
*/
|
||||
export const downloadCsvTemplate = () => {
|
||||
const csvContent = 'Username,Email\n,example@example.com';
|
||||
const csvContent = 'Username,Email,Password\n,example@example.com,';
|
||||
downloadCsv(csvContent, 'user_import_template.csv');
|
||||
};
|
||||
|
||||
@@ -69,10 +106,10 @@ export const downloadCsvTemplate = () => {
|
||||
*/
|
||||
export const exportImportResults = (results: ParsedUser[]) => {
|
||||
const csvContent = [
|
||||
'Username,Email,status',
|
||||
'Username,Email,Password,Status',
|
||||
...results.map(
|
||||
user =>
|
||||
`${user.name || ''},${user.email},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
|
||||
`${user.name || ''},${user.email},${user.password || ''},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
@@ -138,6 +175,7 @@ export const processCSVFile = async (
|
||||
const users = dataRows.map(row => ({
|
||||
name: row[0]?.trim() || null,
|
||||
email: row[1]?.trim() || '',
|
||||
password: row[2]?.trim() || undefined,
|
||||
}));
|
||||
|
||||
const usersWithEmail = users.filter(user => user.email);
|
||||
@@ -164,3 +202,34 @@ export const processCSVFile = async (
|
||||
onError();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate users
|
||||
*/
|
||||
export const validateUsers = (
|
||||
users: ParsedUser[],
|
||||
passwordLimits: { minLength: number; maxLength: number }
|
||||
): ParsedUser[] => {
|
||||
// validate emails
|
||||
const emailValidatedUsers = validateEmails(users);
|
||||
|
||||
// validate password
|
||||
return emailValidatedUsers.map(user => {
|
||||
// if email is invalid, return
|
||||
if (user.valid === false) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// validate password
|
||||
const passwordValidation = validatePassword(user.password, passwordLimits);
|
||||
if (!passwordValidation.valid) {
|
||||
return {
|
||||
...user,
|
||||
valid: false,
|
||||
error: passwordValidation.error,
|
||||
};
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user