mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 13:58:50 +08:00
feat: improve admin panel (#14180)
This commit is contained in:
@@ -66,6 +66,9 @@ export const useColumns = ({
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
meta: {
|
||||
className: 'w-[40px] flex-shrink-0',
|
||||
},
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
@@ -127,6 +130,9 @@ export const useColumns = ({
|
||||
},
|
||||
{
|
||||
accessorKey: 'info',
|
||||
meta: {
|
||||
className: 'w-[250px] flex-shrink-0',
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs"
|
||||
@@ -190,7 +196,7 @@ export const useColumns = ({
|
||||
<DataTableColumnHeader
|
||||
className="text-xs max-md:hidden"
|
||||
column={column}
|
||||
title="UUID"
|
||||
title="User Detail"
|
||||
/>
|
||||
),
|
||||
cell: ({ row: { original: user } }) => (
|
||||
@@ -233,12 +239,40 @@ export const useColumns = ({
|
||||
textFalse="Email Not Verified"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{user.features.length ? (
|
||||
user.features.map(feature => (
|
||||
<span
|
||||
key={feature}
|
||||
className="rounded px-2 py-0.5 text-xs h-5 border inline-flex items-center"
|
||||
style={{
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/white'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
color: cssVarV2('text/secondary'),
|
||||
}}
|
||||
>
|
||||
No features
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
meta: {
|
||||
className: 'w-[80px]',
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs"
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useTransition } from 'react';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// to handle the error: a component suspended while responding to synchronous input.
|
||||
// This will cause the UI to be replaced with a loading indicator.
|
||||
// To fix, updates that suspend should be wrapped with startTransition.
|
||||
const onPageSizeChange = useCallback(
|
||||
(value: string) => {
|
||||
startTransition(() => {
|
||||
table.setPageSize(Number(value));
|
||||
});
|
||||
},
|
||||
[table]
|
||||
);
|
||||
const handleFirstPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(0);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.previousPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleNextPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.nextPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleLastPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between md:px-2">
|
||||
<div className="flex items-center md:space-x-2">
|
||||
<p className="text-sm font-medium max-md:hidden">Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={onPageSizeChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 40, 80].map(pageSize => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={handleFirstPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleNextPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={handleLastPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+26
-21
@@ -16,11 +16,11 @@ import {
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { DiscardChanges } from '../../../components/shared/discard-changes';
|
||||
import { useRightPanel } from '../../panel/context';
|
||||
import type { UserType } from '../schema';
|
||||
import { DeleteAccountDialog } from './delete-account';
|
||||
import { DisableAccountDialog } from './disable-account';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { EnableAccountDialog } from './enable-account';
|
||||
import { ResetPasswordDialog } from './reset-password';
|
||||
import {
|
||||
@@ -41,7 +41,14 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
||||
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const { openPanel, isOpen, closePanel, setPanelContent } = useRightPanel();
|
||||
const {
|
||||
openPanel,
|
||||
isOpen,
|
||||
closePanel,
|
||||
setPanelContent,
|
||||
hasDirtyChanges,
|
||||
setHasDirtyChanges,
|
||||
} = useRightPanel();
|
||||
|
||||
const deleteUser = useDeleteUser();
|
||||
const disableUser = useDisableUser();
|
||||
@@ -118,44 +125,42 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
setEnableDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChangesCancel = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setHasDirtyChanges(false);
|
||||
setPanelContent(
|
||||
<UpdateUserForm
|
||||
user={user}
|
||||
onComplete={closePanel}
|
||||
onResetPassword={openResetPasswordDialog}
|
||||
onDeleteAccount={openDeleteDialog}
|
||||
onDirtyChange={setHasDirtyChanges}
|
||||
/>
|
||||
);
|
||||
if (discardDialogOpen) {
|
||||
handleDiscardChangesCancel();
|
||||
}
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
openPanel();
|
||||
}, [
|
||||
closePanel,
|
||||
discardDialogOpen,
|
||||
handleDiscardChangesCancel,
|
||||
isOpen,
|
||||
openDeleteDialog,
|
||||
openPanel,
|
||||
openResetPasswordDialog,
|
||||
setPanelContent,
|
||||
user,
|
||||
setHasDirtyChanges,
|
||||
]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (isOpen) {
|
||||
if (hasDirtyChanges) {
|
||||
setDiscardDialogOpen(true);
|
||||
} else {
|
||||
handleConfirm();
|
||||
return;
|
||||
}
|
||||
}, [handleConfirm, isOpen]);
|
||||
setHasDirtyChanges(false);
|
||||
handleConfirm();
|
||||
}, [handleConfirm, hasDirtyChanges, setHasDirtyChanges]);
|
||||
|
||||
const handleDiscardConfirm = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
setHasDirtyChanges(false);
|
||||
handleConfirm();
|
||||
}, [handleConfirm, setHasDirtyChanges]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
@@ -242,8 +247,8 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
<DiscardChanges
|
||||
open={discardDialogOpen}
|
||||
onOpenChange={setDiscardDialogOpen}
|
||||
onClose={handleDiscardChangesCancel}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={() => setDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,126 +1,99 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { getUserByEmailQuery } from '@affine/graphql';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
startTransition,
|
||||
type ChangeEvent,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { DiscardChanges } from '../../../components/shared/discard-changes';
|
||||
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
|
||||
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
|
||||
import { useServerConfig } from '../../common';
|
||||
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';
|
||||
import { CreateUserForm } from './user-form';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
data: TData[];
|
||||
usersCount: number;
|
||||
selectedUsers: UserType[];
|
||||
setDataTable: (data: TData[]) => void;
|
||||
setRowCount: (rowCount: number) => void;
|
||||
setMemoUsers: Dispatch<SetStateAction<UserType[]>>;
|
||||
table?: Table<TData>;
|
||||
}
|
||||
|
||||
const useSearch = () => {
|
||||
const [value, setValue] = useState('');
|
||||
const { data } = useQuery({
|
||||
query: getUserByEmailQuery,
|
||||
variables: { email: value },
|
||||
});
|
||||
|
||||
const result = useMemo(() => data?.userByEmail, [data]);
|
||||
|
||||
return {
|
||||
result,
|
||||
query: setValue,
|
||||
};
|
||||
};
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
keyword: string;
|
||||
onKeywordChange: Dispatch<SetStateAction<string>>;
|
||||
selectedFeatures: FeatureType[];
|
||||
onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
data,
|
||||
usersCount,
|
||||
selectedUsers,
|
||||
setDataTable,
|
||||
setRowCount,
|
||||
setMemoUsers,
|
||||
table,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
selectedFeatures,
|
||||
onFeaturesChange,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const [value, setValue] = useState('');
|
||||
const [value, setValue] = useState(keyword);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const debouncedValue = useDebouncedValue(value, 1000);
|
||||
const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel();
|
||||
const { result, query } = useSearch();
|
||||
const debouncedValue = useDebouncedValue(value, 500);
|
||||
const {
|
||||
setPanelContent,
|
||||
openPanel,
|
||||
closePanel,
|
||||
isOpen,
|
||||
hasDirtyChanges,
|
||||
setHasDirtyChanges,
|
||||
} = useRightPanel();
|
||||
const serverConfig = useServerConfig();
|
||||
const availableFeatures = serverConfig.availableUserFeatures ?? [];
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setPanelContent(<CreateUserForm onComplete={closePanel} />);
|
||||
setPanelContent(
|
||||
<CreateUserForm
|
||||
onComplete={closePanel}
|
||||
onDirtyChange={setHasDirtyChanges}
|
||||
/>
|
||||
);
|
||||
if (dialogOpen) {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [setPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
query(debouncedValue);
|
||||
}, [debouncedValue, query]);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
if (!debouncedValue) {
|
||||
setDataTable(data);
|
||||
setRowCount(usersCount);
|
||||
} else if (result) {
|
||||
setMemoUsers(prev => [...new Set([...prev, result])]);
|
||||
setDataTable([result as TData]);
|
||||
setRowCount(1);
|
||||
} else {
|
||||
setDataTable([]);
|
||||
setRowCount(0);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
data,
|
||||
debouncedValue,
|
||||
result,
|
||||
setDataTable,
|
||||
setMemoUsers,
|
||||
setRowCount,
|
||||
usersCount,
|
||||
setPanelContent,
|
||||
closePanel,
|
||||
dialogOpen,
|
||||
isOpen,
|
||||
openPanel,
|
||||
setHasDirtyChanges,
|
||||
]);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: { currentTarget: { value: SetStateAction<string> } }) => {
|
||||
setValue(e.currentTarget.value);
|
||||
useEffect(() => {
|
||||
setValue(keyword);
|
||||
}, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
onKeywordChange(debouncedValue.trim());
|
||||
}, [debouncedValue, onKeywordChange]);
|
||||
|
||||
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.currentTarget.value);
|
||||
}, []);
|
||||
|
||||
const handleFeatureToggle = useCallback(
|
||||
(features: FeatureType[]) => {
|
||||
onFeaturesChange(features);
|
||||
},
|
||||
[]
|
||||
[onFeaturesChange]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
@@ -128,11 +101,11 @@ export function DataTableToolbar<TData>({
|
||||
}, []);
|
||||
|
||||
const handleOpenConfirm = useCallback(() => {
|
||||
if (isOpen) {
|
||||
if (hasDirtyChanges) {
|
||||
return setDialogOpen(true);
|
||||
}
|
||||
return handleConfirm();
|
||||
}, [handleConfirm, isOpen]);
|
||||
}, [handleConfirm, hasDirtyChanges]);
|
||||
|
||||
const handleExportUsers = useCallback(() => {
|
||||
if (!table) return;
|
||||
@@ -192,9 +165,15 @@ export function DataTableToolbar<TData>({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
||||
<FeatureFilterPopover
|
||||
selectedFeatures={selectedFeatures}
|
||||
availableFeatures={availableFeatures}
|
||||
onChange={handleFeatureToggle}
|
||||
align="end"
|
||||
/>
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder="Search Email"
|
||||
placeholder="Search Email / UUID"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
PaginationState,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { SharedDataTable } from '../../../components/shared/data-table';
|
||||
import type { UserType } from '../schema';
|
||||
import { DataTablePagination } from './data-table-pagination';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
@@ -28,7 +16,10 @@ interface DataTableProps<TData, TValue> {
|
||||
pagination: PaginationState;
|
||||
usersCount: number;
|
||||
selectedUsers: UserType[];
|
||||
setMemoUsers: Dispatch<SetStateAction<UserType[]>>;
|
||||
keyword: string;
|
||||
onKeywordChange: Dispatch<SetStateAction<string>>;
|
||||
selectedFeatures: FeatureType[];
|
||||
onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
|
||||
onPaginationChange: Dispatch<
|
||||
SetStateAction<{
|
||||
pageIndex: number;
|
||||
@@ -43,139 +34,46 @@ export function DataTable<TData extends { id: string }, TValue>({
|
||||
pagination,
|
||||
usersCount,
|
||||
selectedUsers,
|
||||
setMemoUsers,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
selectedFeatures,
|
||||
onFeaturesChange,
|
||||
onPaginationChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
const [tableData, setTableData] = useState(data);
|
||||
const [rowCount, setRowCount] = useState(usersCount);
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: row => row.id,
|
||||
manualPagination: true,
|
||||
rowCount: rowCount,
|
||||
enableFilters: true,
|
||||
onPaginationChange: onPaginationChange,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
});
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
setTableData(data);
|
||||
}, [data]);
|
||||
setRowSelection({});
|
||||
}, [keyword, selectedFeatures]);
|
||||
|
||||
useEffect(() => {
|
||||
setRowCount(usersCount);
|
||||
}, [usersCount]);
|
||||
const selection: Record<string, boolean> = {};
|
||||
selectedUsers.forEach(user => {
|
||||
selection[user.id] = true;
|
||||
});
|
||||
setRowSelection(selection);
|
||||
}, [selectedUsers]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto">
|
||||
<DataTableToolbar
|
||||
setDataTable={setTableData}
|
||||
data={data}
|
||||
usersCount={usersCount}
|
||||
table={table}
|
||||
selectedUsers={selectedUsers}
|
||||
setRowCount={setRowCount}
|
||||
setMemoUsers={setMemoUsers}
|
||||
/>
|
||||
<div className="rounded-md border h-full flex flex-col overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<TableRow key={headerGroup.id} className="flex items-center">
|
||||
{headerGroup.headers.map(header => {
|
||||
let columnClassName = '';
|
||||
if (header.id === 'select') {
|
||||
columnClassName = 'w-[40px] flex-shrink-0';
|
||||
} else if (header.id === 'info') {
|
||||
columnClassName = 'flex-1';
|
||||
} else if (header.id === 'property') {
|
||||
columnClassName = 'flex-1';
|
||||
} else if (header.id === 'actions') {
|
||||
columnClassName =
|
||||
'w-[40px] flex-shrink-0 justify-center mr-6';
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={`${columnClassName} py-2 text-xs flex items-center h-9`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
</Table>
|
||||
|
||||
<div className="overflow-auto flex-1">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow key={row.id} className="flex items-center">
|
||||
{row.getVisibleCells().map(cell => {
|
||||
let columnClassName = '';
|
||||
if (cell.column.id === 'select') {
|
||||
columnClassName = 'w-[40px] flex-shrink-0';
|
||||
} else if (cell.column.id === 'info') {
|
||||
columnClassName = 'flex-1';
|
||||
} else if (cell.column.id === 'property') {
|
||||
columnClassName = 'flex-1';
|
||||
} else if (cell.column.id === 'actions') {
|
||||
columnClassName =
|
||||
'w-[40px] flex-shrink-0 justify-center mr-6';
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`${columnClassName} flex items-center`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
<SharedDataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
totalCount={usersCount}
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
resetFiltersDeps={[keyword, selectedFeatures]}
|
||||
renderToolbar={table => (
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
selectedUsers={selectedUsers}
|
||||
keyword={keyword}
|
||||
onKeywordChange={onKeywordChange}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onFeaturesChange={onFeaturesChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
|
||||
|
||||
export const DeleteAccountDialog = ({
|
||||
email,
|
||||
@@ -23,55 +13,23 @@ export const DeleteAccountDialog = ({
|
||||
onDelete: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const handleInput = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(event.target.value);
|
||||
},
|
||||
[setInput]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Account ?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-bold">{email}</span> will be permanently
|
||||
deleted. This operation is irreversible. Please proceed with
|
||||
caution.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
placeholder="Please type email to confirm"
|
||||
className="placeholder:opacity-50 mt-4 h-9"
|
||||
/>
|
||||
<DialogFooter className="mt-6">
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={input !== email}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TypeConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Delete Account ?"
|
||||
description={
|
||||
<>
|
||||
<span className="font-bold">{email}</span> will be permanently
|
||||
deleted. This operation is irreversible. Please proceed with caution.
|
||||
</>
|
||||
}
|
||||
targetText={email}
|
||||
inputPlaceholder="Please type email to confirm"
|
||||
confirmText="Delete"
|
||||
confirmButtonVariant="destructive"
|
||||
onConfirm={onDelete}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
|
||||
|
||||
export const DisableAccountDialog = ({
|
||||
email,
|
||||
@@ -23,55 +13,24 @@ export const DisableAccountDialog = ({
|
||||
onDisable: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const handleInput = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(event.target.value);
|
||||
},
|
||||
[setInput]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Account ?</DialogTitle>
|
||||
<DialogDescription>
|
||||
The data associated with <span className="font-bold">{email}</span>{' '}
|
||||
will be deleted and cannot be used for logging in. This operation is
|
||||
irreversible. Please proceed with caution.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
placeholder="Please type email to confirm"
|
||||
className="placeholder:opacity-50 mt-4 h-9"
|
||||
/>
|
||||
<DialogFooter className="mt-6">
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onDisable}
|
||||
disabled={input !== email}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TypeConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Disable Account ?"
|
||||
description={
|
||||
<>
|
||||
The data associated with <span className="font-bold">{email}</span>{' '}
|
||||
will be deleted and cannot be used for logging in. This operation is
|
||||
irreversible. Please proceed with caution.
|
||||
</>
|
||||
}
|
||||
targetText={email}
|
||||
inputPlaceholder="Please type email to confirm"
|
||||
confirmText="Disable"
|
||||
confirmButtonVariant="destructive"
|
||||
onConfirm={onDisable}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
|
||||
export const DiscardChanges = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Discard Changes</DialogTitle>
|
||||
<DialogDescription className="leading-6 text-[15px]">
|
||||
Changes to this user will not be saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<Button type="button" onClick={onClose} variant="outline">
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm} variant="destructive">
|
||||
<span>Discard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,4 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { ConfirmDialog } from '../../../components/shared/confirm-dialog';
|
||||
|
||||
export const EnableAccountDialog = ({
|
||||
open,
|
||||
@@ -22,27 +14,21 @@ export const EnableAccountDialog = ({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">Enable Account</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Are you sure you want to enable the account? After enabling the
|
||||
account, the <span className="font-bold">{email}</span> email can be
|
||||
used to log in.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<Button type="button" onClick={onClose} variant="outline">
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm} variant="default">
|
||||
<span>Enable</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Enable Account"
|
||||
description={
|
||||
<>
|
||||
Are you sure you want to enable the account? After enabling the
|
||||
account, the <span className="font-bold">{email}</span> email can be
|
||||
used to log in.
|
||||
</>
|
||||
}
|
||||
confirmText="Enable"
|
||||
confirmButtonVariant="default"
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
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';
|
||||
@@ -10,6 +9,7 @@ import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
|
||||
import { useServerConfig } from '../../common';
|
||||
import { RightPanelHeader } from '../../header';
|
||||
import type { UserInput, UserType } from '../schema';
|
||||
@@ -24,6 +24,7 @@ type UserFormProps = {
|
||||
onValidate: (user: Partial<UserInput>) => boolean;
|
||||
actions?: React.ReactNode;
|
||||
showOption?: boolean;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
function UserForm({
|
||||
@@ -34,6 +35,7 @@ function UserForm({
|
||||
onValidate,
|
||||
actions,
|
||||
showOption,
|
||||
onDirtyChange,
|
||||
}: UserFormProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
@@ -67,6 +69,24 @@ function UserForm({
|
||||
return onValidate(changes);
|
||||
}, [onValidate, changes]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalize = (value: Partial<UserInput>) => ({
|
||||
name: value.name ?? '',
|
||||
email: value.email ?? '',
|
||||
password: value.password ?? '',
|
||||
features: [...(value.features ?? [])].sort(),
|
||||
});
|
||||
const current = normalize(changes);
|
||||
const baseline = normalize(defaultUser);
|
||||
const dirty =
|
||||
(current.name !== baseline.name ||
|
||||
current.email !== baseline.email ||
|
||||
current.password !== baseline.password ||
|
||||
current.features.join(',') !== baseline.features.join(',')) &&
|
||||
!!onDirtyChange;
|
||||
onDirtyChange?.(dirty);
|
||||
}, [changes, defaultUser, onDirtyChange]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canSave) {
|
||||
return;
|
||||
@@ -77,14 +97,9 @@ function UserForm({
|
||||
setChanges(defaultUser);
|
||||
}, [canSave, changes, defaultUser, onConfirm]);
|
||||
|
||||
const onFeatureChanged = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
setField('features', (features = []) => {
|
||||
if (checked) {
|
||||
return [...features, feature];
|
||||
}
|
||||
return features.filter(f => f !== feature);
|
||||
});
|
||||
const handleFeaturesChange = useCallback(
|
||||
(features: FeatureType[]) => {
|
||||
setField('features', features);
|
||||
},
|
||||
[setField]
|
||||
);
|
||||
@@ -138,52 +153,21 @@ function UserForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
{serverConfig.availableUserFeatures.map((feature, i) => (
|
||||
<div key={feature}>
|
||||
<ToggleItem
|
||||
name={feature}
|
||||
checked={changes.features?.includes(feature) ?? false}
|
||||
onChange={onFeatureChanged}
|
||||
/>
|
||||
{i < serverConfig.availableUserFeatures.length - 1 && (
|
||||
<Separator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FeatureToggleList
|
||||
className="border rounded-md"
|
||||
features={serverConfig.availableUserFeatures}
|
||||
selected={changes.features ?? []}
|
||||
onChange={handleFeaturesChange}
|
||||
control="switch"
|
||||
controlPosition="right"
|
||||
showSeparators={true}
|
||||
/>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleItem({
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
name: FeatureType;
|
||||
checked: boolean;
|
||||
onChange: (name: FeatureType, value: boolean) => void;
|
||||
}) {
|
||||
const onToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange(name, checked);
|
||||
},
|
||||
[name, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Label className="flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden">
|
||||
<span className="overflow-hidden text-ellipsis" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function InputItem({
|
||||
label,
|
||||
field,
|
||||
@@ -241,7 +225,13 @@ const validateUpdateUser = (user: Partial<UserInput>) => {
|
||||
return !!user.name || !!user.email;
|
||||
};
|
||||
|
||||
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
export function CreateUserForm({
|
||||
onComplete,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
onComplete: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { create, creating } = useCreateUser();
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
@@ -278,6 +268,7 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
onConfirm={handleCreateUser}
|
||||
onValidate={validateCreateUser}
|
||||
showOption={true}
|
||||
onDirtyChange={onDirtyChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -287,11 +278,13 @@ export function UpdateUserForm({
|
||||
onResetPassword,
|
||||
onDeleteAccount,
|
||||
onComplete,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
user: UserType;
|
||||
onResetPassword: () => void;
|
||||
onDeleteAccount: () => void;
|
||||
onComplete: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { update, updating } = useUpdateUser();
|
||||
|
||||
@@ -321,6 +314,7 @@ export function UpdateUserForm({
|
||||
onClose={onComplete}
|
||||
onConfirm={onUpdateUser}
|
||||
onValidate={validateUpdateUser}
|
||||
onDirtyChange={onDirtyChange}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user