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:
JimmFly
2025-05-13 02:43:01 +00:00
parent 1426a38c9f
commit 662614de0d
14 changed files with 764 additions and 336 deletions

View File

@@ -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>

View File

@@ -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> {

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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;
};

View File

@@ -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>
</>
);

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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,
},
});

View File

@@ -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}
/>
);
}

View File

@@ -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">

View File

@@ -4,5 +4,6 @@ export type UserType = ListUsersQuery['users'][0];
export type UserInput = {
name: string;
email: string;
password?: string;
features: FeatureType[];
};

View File

@@ -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;
});
};