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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
|
<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
|
<DropdownMenuItem
|
||||||
onSelect={handleEdit}
|
onSelect={handleEdit}
|
||||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
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>
|
</DropdownMenuItem>
|
||||||
{user.disabled && (
|
{user.disabled && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||||
onSelect={openEnableDialog}
|
onSelect={openEnableDialog}
|
||||||
>
|
>
|
||||||
<AccountBanIcon fontSize={20} /> Enable Email
|
<AccountBanIcon fontSize={20} />
|
||||||
|
Enable Email
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<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"
|
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||||
onSelect={openDisableDialog}
|
onSelect={openDisableDialog}
|
||||||
>
|
>
|
||||||
<AccountBanIcon fontSize={20} /> Disable & Delete data
|
<AccountBanIcon fontSize={20} />
|
||||||
|
Disable & Delete data
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||||
onSelect={openDeleteDialog}
|
onSelect={openDeleteDialog}
|
||||||
>
|
>
|
||||||
<DeleteIcon fontSize={20} /> Delete
|
<DeleteIcon fontSize={20} />
|
||||||
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useRightPanel } from '../../panel/context';
|
|||||||
import type { UserType } from '../schema';
|
import type { UserType } from '../schema';
|
||||||
import { DiscardChanges } from './discard-changes';
|
import { DiscardChanges } from './discard-changes';
|
||||||
import { ExportUsersDialog } from './export-users-dialog';
|
import { ExportUsersDialog } from './export-users-dialog';
|
||||||
import { ImportUsersDialog } from './import-users-dialog';
|
import { ImportUsersDialog } from './import-users';
|
||||||
import { CreateUserForm } from './user-form';
|
import { CreateUserForm } from './user-form';
|
||||||
|
|
||||||
interface DataTableToolbarProps<TData> {
|
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 revalidate = useMutateQueryResource();
|
||||||
|
|
||||||
const create = useAsyncCallback(
|
const create = useAsyncCallback(
|
||||||
async ({ name, email, features }: UserInput) => {
|
async ({ name, email, password, features }: UserInput) => {
|
||||||
try {
|
try {
|
||||||
const account = await createAccount({
|
const account = await createAccount({
|
||||||
input: {
|
input: {
|
||||||
name,
|
name,
|
||||||
email,
|
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 { Separator } from '@affine/admin/components/ui/separator';
|
||||||
import { Switch } from '@affine/admin/components/ui/switch';
|
import { Switch } from '@affine/admin/components/ui/switch';
|
||||||
import type { FeatureType } from '@affine/graphql';
|
import type { FeatureType } from '@affine/graphql';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { ChevronRightIcon } from 'lucide-react';
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { useServerConfig } from '../../common';
|
import { useServerConfig } from '../../common';
|
||||||
import { RightPanelHeader } from '../../header';
|
import { RightPanelHeader } from '../../header';
|
||||||
import type { UserInput, UserType } from '../schema';
|
import type { UserInput, UserType } from '../schema';
|
||||||
|
import { validateEmails, validatePassword } from '../utils/csv-utils';
|
||||||
import { useCreateUser, useUpdateUser } from './use-user-management';
|
import { useCreateUser, useUpdateUser } from './use-user-management';
|
||||||
|
|
||||||
type UserFormProps = {
|
type UserFormProps = {
|
||||||
@@ -20,6 +23,7 @@ type UserFormProps = {
|
|||||||
onConfirm: (user: UserInput) => void;
|
onConfirm: (user: UserInput) => void;
|
||||||
onValidate: (user: Partial<UserInput>) => boolean;
|
onValidate: (user: Partial<UserInput>) => boolean;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
|
showOption?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function UserForm({
|
function UserForm({
|
||||||
@@ -29,6 +33,7 @@ function UserForm({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onValidate,
|
onValidate,
|
||||||
actions,
|
actions,
|
||||||
|
showOption,
|
||||||
}: UserFormProps) {
|
}: UserFormProps) {
|
||||||
const serverConfig = useServerConfig();
|
const serverConfig = useServerConfig();
|
||||||
|
|
||||||
@@ -36,9 +41,10 @@ function UserForm({
|
|||||||
() => ({
|
() => ({
|
||||||
name: defaultValue?.name ?? '',
|
name: defaultValue?.name ?? '',
|
||||||
email: defaultValue?.email ?? '',
|
email: defaultValue?.email ?? '',
|
||||||
|
password: defaultValue?.password ?? '',
|
||||||
features: defaultValue?.features ?? [],
|
features: defaultValue?.features ?? [],
|
||||||
}),
|
}),
|
||||||
[defaultValue?.email, defaultValue?.features, defaultValue?.name]
|
[defaultValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [changes, setChanges] = useState<Partial<UserInput>>(defaultUser);
|
const [changes, setChanges] = useState<Partial<UserInput>>(defaultUser);
|
||||||
@@ -68,7 +74,8 @@ function UserForm({
|
|||||||
|
|
||||||
// @ts-expect-error checked
|
// @ts-expect-error checked
|
||||||
onConfirm(changes);
|
onConfirm(changes);
|
||||||
}, [canSave, changes, onConfirm]);
|
setChanges(defaultUser);
|
||||||
|
}, [canSave, changes, defaultUser, onConfirm]);
|
||||||
|
|
||||||
const onFeatureChanged = useCallback(
|
const onFeatureChanged = useCallback(
|
||||||
(feature: FeatureType, checked: boolean) => {
|
(feature: FeatureType, checked: boolean) => {
|
||||||
@@ -116,6 +123,19 @@ function UserForm({
|
|||||||
onChange={setField}
|
onChange={setField}
|
||||||
placeholder="Enter email address"
|
placeholder="Enter email address"
|
||||||
/>
|
/>
|
||||||
|
{showOption && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<InputItem
|
||||||
|
label="Password"
|
||||||
|
field="password"
|
||||||
|
value={changes.password}
|
||||||
|
onChange={setField}
|
||||||
|
optional
|
||||||
|
placeholder="Enter password"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md">
|
||||||
@@ -167,12 +187,14 @@ function ToggleItem({
|
|||||||
function InputItem({
|
function InputItem({
|
||||||
label,
|
label,
|
||||||
field,
|
field,
|
||||||
|
optional,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
field: keyof UserInput;
|
field: keyof UserInput;
|
||||||
|
optional?: boolean;
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange: (field: keyof UserInput, value: string) => void;
|
onChange: (field: keyof UserInput, value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -185,9 +207,20 @@ function InputItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3">
|
<div className="flex flex-col gap-1.5 p-3">
|
||||||
<Label className="text-[15px] leading-6 font-medium mb-1.5">
|
<Label
|
||||||
|
className="text-[15px] font-medium flex-wrap flex"
|
||||||
|
style={{ lineHeight: '1.6rem' }}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
|
{optional && (
|
||||||
|
<span
|
||||||
|
className="font-normal ml-1"
|
||||||
|
style={{ color: cssVarV2('text/secondary') }}
|
||||||
|
>
|
||||||
|
(optional)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -210,6 +243,8 @@ const validateUpdateUser = (user: Partial<UserInput>) => {
|
|||||||
|
|
||||||
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||||
const { create, creating } = useCreateUser();
|
const { create, creating } = useCreateUser();
|
||||||
|
const serverConfig = useServerConfig();
|
||||||
|
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -219,12 +254,30 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}, [creating, onComplete]);
|
}, [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 (
|
return (
|
||||||
<UserForm
|
<UserForm
|
||||||
title="Create User"
|
title="Create User"
|
||||||
onClose={onComplete}
|
onClose={onComplete}
|
||||||
onConfirm={create}
|
onConfirm={handleCreateUser}
|
||||||
onValidate={validateCreateUser}
|
onValidate={validateCreateUser}
|
||||||
|
showOption={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-h-[300px] overflow-y-auto border rounded-md">
|
<div className="max-h-[300px] overflow-y-auto border rounded-md">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<thead className="bg-white sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Username
|
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">
|
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Email
|
Email
|
||||||
</th>
|
</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">
|
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
@@ -37,10 +40,26 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
|||||||
{user.name || '-'}
|
{user.name || '-'}
|
||||||
</td>
|
</td>
|
||||||
<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}
|
{user.email}
|
||||||
</td>
|
</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">
|
<td className="py-2 px-4 text-sm">
|
||||||
{user.importStatus === ImportStatus.Success ? (
|
{user.importStatus === ImportStatus.Success ? (
|
||||||
<span className="text-gray-900">
|
<span className="text-gray-900">
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export type UserType = ListUsersQuery['users'][0];
|
|||||||
export type UserInput = {
|
export type UserInput = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
password?: string;
|
||||||
features: FeatureType[];
|
features: FeatureType[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { emailRegex } from '../../../utils';
|
|||||||
export interface ParsedUser {
|
export interface ParsedUser {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
|
password?: string;
|
||||||
valid?: boolean;
|
valid?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
importStatus?: ImportStatus;
|
importStatus?: ImportStatus;
|
||||||
@@ -17,6 +18,41 @@ export enum ImportStatus {
|
|||||||
Processing = 'processing',
|
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
|
* Validates email addresses for duplicates and format
|
||||||
*/
|
*/
|
||||||
@@ -53,6 +89,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
|
|||||||
.map(user => ({
|
.map(user => ({
|
||||||
name: user.name || undefined,
|
name: user.name || undefined,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
password: user.password,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +97,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
|
|||||||
* Downloads a CSV template for user import
|
* Downloads a CSV template for user import
|
||||||
*/
|
*/
|
||||||
export const downloadCsvTemplate = () => {
|
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');
|
downloadCsv(csvContent, 'user_import_template.csv');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,10 +106,10 @@ export const downloadCsvTemplate = () => {
|
|||||||
*/
|
*/
|
||||||
export const exportImportResults = (results: ParsedUser[]) => {
|
export const exportImportResults = (results: ParsedUser[]) => {
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
'Username,Email,status',
|
'Username,Email,Password,Status',
|
||||||
...results.map(
|
...results.map(
|
||||||
user =>
|
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');
|
].join('\n');
|
||||||
|
|
||||||
@@ -138,6 +175,7 @@ export const processCSVFile = async (
|
|||||||
const users = dataRows.map(row => ({
|
const users = dataRows.map(row => ({
|
||||||
name: row[0]?.trim() || null,
|
name: row[0]?.trim() || null,
|
||||||
email: row[1]?.trim() || '',
|
email: row[1]?.trim() || '',
|
||||||
|
password: row[2]?.trim() || undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const usersWithEmail = users.filter(user => user.email);
|
const usersWithEmail = users.filter(user => user.email);
|
||||||
@@ -164,3 +202,34 @@ export const processCSVFile = async (
|
|||||||
onError();
|
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