mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat(admin): add self-host setup and user management page (#7537)
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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 { FeatureType } from '@affine/graphql';
|
||||
import { CheckIcon, XIcon } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import { useUserManagement } from './use-user-management';
|
||||
|
||||
export function CreateUserPanel() {
|
||||
const { closePanel } = useRightPanel();
|
||||
const [name, setName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [features, setFeatures] = useState<FeatureType[]>([]);
|
||||
|
||||
const disableSave = !name || !email;
|
||||
|
||||
const { createUser } = useUserManagement();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
createUser({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
features,
|
||||
callback: closePanel,
|
||||
});
|
||||
}, [closePanel, createUser, email, features, name, password]);
|
||||
|
||||
const onEarlyAccessChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
setFeatures(
|
||||
checked
|
||||
? [...features, FeatureType.AIEarlyAccess]
|
||||
: features.filter(f => f !== FeatureType.AIEarlyAccess)
|
||||
);
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
const onAdminChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
setFeatures(
|
||||
checked
|
||||
? [...features, FeatureType.Admin]
|
||||
: features.filter(f => f !== FeatureType.Admin)
|
||||
);
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-1">
|
||||
<div className="flex justify-between items-center py-[10px] px-6">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={closePanel}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</Button>
|
||||
<span className="text-base font-medium">Create Account</span>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={handleConfirm}
|
||||
disabled={disableSave}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
|
||||
<div className="flex flex-col rounded-md border py-4 gap-4">
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
className="py-2 px-3 text-base font-normal"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
className="py-2 px-3 ext-base font-normal"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>{' '}
|
||||
<Separator />
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="py-2 px-3 ext-base font-normal"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<Label className="flex items-center justify-between px-4 py-3">
|
||||
<span>Enable AI Access</span>
|
||||
<Switch
|
||||
checked={features.includes(FeatureType.AIEarlyAccess)}
|
||||
onCheckedChange={onEarlyAccessChange}
|
||||
/>
|
||||
</Label>
|
||||
<Separator />
|
||||
<Label className="flex items-center justify-between px-4 py-3">
|
||||
<span>Admin</span>
|
||||
<Switch
|
||||
checked={features.includes(FeatureType.Admin)}
|
||||
onCheckedChange={onAdminChange}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
MailWarningIcon,
|
||||
UnlockIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { User } from '../schema';
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
const StatusItem = ({
|
||||
condition,
|
||||
IconTrue,
|
||||
IconFalse,
|
||||
textTrue,
|
||||
textFalse,
|
||||
}: {
|
||||
condition: boolean | null;
|
||||
IconTrue: ReactNode;
|
||||
IconFalse: ReactNode;
|
||||
textTrue: string;
|
||||
textFalse: string;
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-2 items-center',
|
||||
!condition ? 'text-red-500 opacity-100' : 'opacity-25'
|
||||
)}
|
||||
>
|
||||
{condition ? (
|
||||
<>
|
||||
{IconTrue}
|
||||
{textTrue}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{IconFalse}
|
||||
{textFalse}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const columns: ColumnDef<User>[] = [
|
||||
{
|
||||
accessorKey: 'info',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-3 items-center max-w-[50vw] overflow-hidden">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<UserIcon size={20} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1 max-w-full overflow-hidden">
|
||||
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
||||
<span>{row.original.name}</span>{' '}
|
||||
{row.original.features.includes(FeatureType.Admin) && (
|
||||
<span
|
||||
className="rounded p-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'rgba(30, 150, 235, 0.20)',
|
||||
color: 'rgba(30, 150, 235, 1)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-medium opacity-50 max-w-full overflow-hidden">
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'property',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
||||
<div className="flex justify-end opacity-25">{row.original.id}</div>
|
||||
<div className="flex gap-3 items-center justify-end">
|
||||
<StatusItem
|
||||
condition={row.original.hasPassword}
|
||||
IconTrue={<LockIcon size={10} />}
|
||||
IconFalse={<UnlockIcon size={10} />}
|
||||
textTrue="Password Set"
|
||||
textFalse="No Password"
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
condition={row.original.emailVerified}
|
||||
IconTrue={<MailIcon size={10} />}
|
||||
IconFalse={<MailWarningIcon size={10} />}
|
||||
textTrue="Email Verified"
|
||||
textFalse="Email Not Verified"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableRowActions row={row} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,125 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import {
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import { userSchema } from '../schema';
|
||||
import { DeleteAccountDialog } from './delete-account';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { EditPanel } from './edit-panel';
|
||||
import { ResetPasswordDialog } from './reset-password';
|
||||
import { useUserManagement } from './use-user-management';
|
||||
|
||||
interface DataTableRowActionsProps<TData> {
|
||||
row: Row<TData>;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<TData>({
|
||||
row,
|
||||
}: DataTableRowActionsProps<TData>) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const user = userSchema.parse(row.original);
|
||||
const { setRightPanelContent, openPanel, isOpen, closePanel } =
|
||||
useRightPanel();
|
||||
|
||||
const { deleteUser, resetPasswordLink, onResetPassword } =
|
||||
useUserManagement();
|
||||
|
||||
const openResetPasswordDialog = useCallback(() => {
|
||||
onResetPassword(user.id, () => setResetPasswordDialogOpen(true));
|
||||
}, [onResetPassword, user.id]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(resetPasswordLink)
|
||||
.then(() => {
|
||||
toast('Reset password link copied to clipboard');
|
||||
setResetPasswordDialogOpen(false);
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to copy reset password link: ' + e.message);
|
||||
});
|
||||
}, [resetPasswordLink]);
|
||||
|
||||
const onDeleting = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteUser(user.id, onDeleting);
|
||||
}, [deleteUser, onDeleting, user.id]);
|
||||
|
||||
const openDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChangesCancel = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(
|
||||
<EditPanel
|
||||
user={user}
|
||||
onResetPassword={openResetPasswordDialog}
|
||||
onDeleteAccount={openDeleteDialog}
|
||||
/>
|
||||
);
|
||||
if (discardDialogOpen) {
|
||||
handleDiscardChangesCancel();
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [
|
||||
discardDialogOpen,
|
||||
handleDiscardChangesCancel,
|
||||
isOpen,
|
||||
openDeleteDialog,
|
||||
openPanel,
|
||||
openResetPasswordDialog,
|
||||
setRightPanelContent,
|
||||
user,
|
||||
]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (isOpen) {
|
||||
setDiscardDialogOpen(true);
|
||||
} else {
|
||||
handleConfirm();
|
||||
}
|
||||
}, [handleConfirm, isOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<MoreVerticalIcon size={20} />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
|
||||
<div className="px-2 py-[6px] text-sm font-semibold overflow-hidden text-ellipsis text-nowrap">
|
||||
{user.name}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon size={16} /> Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
>
|
||||
<SettingsIcon size={16} /> Edit
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDeleteDialog}
|
||||
>
|
||||
<TrashIcon size={16} /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteAccountDialog
|
||||
email={user.email}
|
||||
open={deleteDialogOpen}
|
||||
onClose={closeDeleteDialog}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<ResetPasswordDialog
|
||||
link={resetPasswordLink}
|
||||
open={resetPasswordDialogOpen}
|
||||
onOpenChange={setResetPasswordDialogOpen}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
<DiscardChanges
|
||||
open={discardDialogOpen}
|
||||
onOpenChange={setDiscardDialogOpen}
|
||||
onClose={handleDiscardChangesCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getUserByEmailQuery } from '@affine/graphql';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import type { SetStateAction } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import { CreateUserPanel } from './ceate-user-panel';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
data: TData[];
|
||||
setDataTable: (data: TData[]) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
data,
|
||||
setDataTable,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const [value, setValue] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const debouncedValue = useDebouncedValue(value, 500);
|
||||
const { setRightPanelContent, openPanel, isOpen } = useRightPanel();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(<CreateUserPanel />);
|
||||
if (dialogOpen) {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [setRightPanelContent, dialogOpen, isOpen, openPanel]);
|
||||
|
||||
const result = useQuery({
|
||||
query: getUserByEmailQuery,
|
||||
variables: {
|
||||
email: value,
|
||||
},
|
||||
}).data.userByEmail;
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
if (!debouncedValue) {
|
||||
setDataTable(data);
|
||||
} else if (result) {
|
||||
setDataTable([result as TData]);
|
||||
} else {
|
||||
setDataTable([]);
|
||||
}
|
||||
});
|
||||
}, [data, debouncedValue, result, setDataTable, value]);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: { currentTarget: { value: SetStateAction<string> } }) => {
|
||||
startTransition(() => {
|
||||
setValue(e.currentTarget.value);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenConfirm = useCallback(() => {
|
||||
if (isOpen) {
|
||||
return setDialogOpen(true);
|
||||
}
|
||||
return handleConfirm();
|
||||
}, [handleConfirm, isOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search Email"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
className="h-10 w-full mr-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="px-4 py-2 space-x-[10px] text-sm font-medium"
|
||||
onClick={handleOpenConfirm}
|
||||
>
|
||||
<PlusIcon size={20} /> <span>Add User</span>
|
||||
</Button>
|
||||
<DiscardChanges
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onClose={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getUsersCountQuery } from '@affine/graphql';
|
||||
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { DataTablePagination } from './data-table-pagination';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: Dispatch<
|
||||
SetStateAction<{
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const {
|
||||
data: { usersCount },
|
||||
} = useQuery({
|
||||
query: getUsersCountQuery,
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState(data);
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: usersCount,
|
||||
enableFilters: true,
|
||||
onPaginationChange: onPaginationChange,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTableData(data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-5 px-6 h-full">
|
||||
<DataTableToolbar setDataTable={setTableData} data={data} />
|
||||
<ScrollArea className="rounded-md border max-h-[75vh] h-full">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<TableCell key={cell.id}>
|
||||
{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>
|
||||
</ScrollArea>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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';
|
||||
|
||||
export const DeleteAccountDialog = ({
|
||||
email,
|
||||
open,
|
||||
onClose,
|
||||
onDelete,
|
||||
onOpenChange,
|
||||
}: {
|
||||
email: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
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"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
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 className="leading-7">Discard Changes</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Changes to this user will not be saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end items-center w-full space-x-4">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
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 { FeatureType } from '@affine/graphql';
|
||||
import { CheckIcon, ChevronRightIcon, XIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import type { User } from '../schema';
|
||||
import { useUserManagement } from './use-user-management';
|
||||
|
||||
interface EditPanelProps {
|
||||
user: User;
|
||||
onResetPassword: () => void;
|
||||
onDeleteAccount: () => void;
|
||||
}
|
||||
|
||||
export function EditPanel({
|
||||
user,
|
||||
onResetPassword,
|
||||
onDeleteAccount,
|
||||
}: EditPanelProps) {
|
||||
const { closePanel } = useRightPanel();
|
||||
const [name, setName] = useState(user.name);
|
||||
const [email, setEmail] = useState(user.email);
|
||||
const [features, setFeatures] = useState(user.features);
|
||||
const { updateUser } = useUserManagement();
|
||||
|
||||
const disableSave =
|
||||
name === user.name && email === user.email && features === user.features;
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
updateUser({
|
||||
userId: user.id,
|
||||
name,
|
||||
email,
|
||||
features,
|
||||
callback: closePanel,
|
||||
});
|
||||
}, [closePanel, email, features, name, updateUser, user.id]);
|
||||
|
||||
const onEarlyAccessChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
setFeatures([...features, FeatureType.AIEarlyAccess]);
|
||||
} else {
|
||||
setFeatures(features.filter(f => f !== FeatureType.AIEarlyAccess));
|
||||
}
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
const onAdminChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
setFeatures([...features, FeatureType.Admin]);
|
||||
} else {
|
||||
setFeatures(features.filter(f => f !== FeatureType.Admin));
|
||||
}
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setName(user.name);
|
||||
setEmail(user.email);
|
||||
setFeatures(user.features);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-1">
|
||||
<div className="flex justify-between items-center py-[10px] px-6">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={closePanel}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</Button>
|
||||
<span className="text-base font-medium">Edit Account</span>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={onConfirm}
|
||||
disabled={disableSave}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
|
||||
<div className="flex flex-col rounded-md border py-4 gap-4">
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
className="py-2 px-3 text-base font-normal"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
className="py-2 px-3 ext-base font-normal"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full flex items-center justify-between text-sm font-medium px-4 py-3"
|
||||
variant="outline"
|
||||
onClick={onResetPassword}
|
||||
>
|
||||
<span>Reset Password</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Button>
|
||||
<div className="border rounded-md">
|
||||
<Label className="flex items-center justify-between px-4 py-3">
|
||||
<span>Enable AI Access</span>
|
||||
<Switch
|
||||
checked={features.includes(FeatureType.AIEarlyAccess)}
|
||||
onCheckedChange={onEarlyAccessChange}
|
||||
/>
|
||||
</Label>
|
||||
<Separator />
|
||||
<Label className="flex items-center justify-between px-4 py-3">
|
||||
<span>Admin</span>
|
||||
<Switch
|
||||
checked={features.includes(FeatureType.Admin)}
|
||||
onCheckedChange={onAdminChange}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full text-red-500 px-4 py-3 rounded-md flex items-center justify-between text-sm font-medium hover:text-red-500"
|
||||
variant="outline"
|
||||
onClick={onDeleteAccount}
|
||||
>
|
||||
<span>Delete Account</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const Logo = () => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.6172 16.2657C18.314 15.7224 17.8091 14.8204 17.3102 13.9295C17.1589 13.6591 17.0086 13.3904 16.8644 13.1326C16.5679 12.6025 16.2978 12.1191 16.1052 11.7741C14.7688 9.38998 12.1376 4.66958 10.823 2.33541C10.418 1.68481 9.47943 1.73636 9.13092 2.4101C8.73553 3.1175 8.3004 3.89538 7.84081 4.71744C7.69509 4.97831 7.54631 5.24392 7.396 5.51268C5.48122 8.93556 3.24035 12.9423 1.64403 15.7961C1.5625 15.9486 1.41067 16.1974 1.33475 16.362C1.20176 16.6591 1.22775 17.0294 1.39538 17.304C1.58441 17.629 1.93802 17.8073 2.29927 17.7889C2.73389 17.7889 3.65561 17.7884 4.84738 17.7889C5.13016 17.7889 5.42823 17.7889 5.73853 17.7889C9.88246 17.7889 16.2127 17.7915 17.7663 17.7889C18.5209 17.7905 18.9942 16.9363 18.6182 16.2652L18.6172 16.2657ZM9.69699 13.2342L8.93424 11.8704C8.80024 11.6305 8.96787 11.3307 9.23588 11.3307H10.7614C11.0299 11.3307 11.1975 11.6305 11.063 11.8704L10.3003 13.2342C10.1663 13.474 9.83099 13.474 9.69648 13.2342H9.69699ZM8.41912 10.6943C8.35594 10.5281 8.30142 10.3593 8.25658 10.1878L10.7802 10.6943H8.41912ZM9.57165 14.2824C9.46414 14.4223 9.3495 14.5553 9.22823 14.6816L8.39109 12.1723L9.57114 14.2824H9.57165ZM12.0061 11.458C12.1768 11.4843 12.346 11.5206 12.5121 11.5658L10.8256 13.5687L12.0061 11.458ZM8.10117 9.33318C8.07417 9.07967 8.06245 8.82353 8.06347 8.56687L11.3962 10.2452L8.10067 9.33371L8.10117 9.33318ZM7.70579 11.8456L8.58828 15.2459C8.38905 15.3969 8.18015 15.5357 7.96411 15.663L7.70528 11.8456H7.70579ZM13.3069 11.8546C13.5332 11.9571 13.7538 12.075 13.9688 12.2043L10.8944 14.345L13.3069 11.8546ZM8.1399 7.48447C8.20104 7.01847 8.2953 6.55932 8.40943 6.1191L13.4725 10.6623L8.14041 7.48447H8.1399ZM7.01793 16.1369C6.59656 16.3152 6.16449 16.4603 5.73802 16.5781L7.01793 9.78129V16.1369ZM14.8386 12.8134C15.1988 13.1011 15.5371 13.4151 15.8494 13.737L9.50643 15.9912L14.8386 12.8134ZM10.2203 3.56456C11.1537 5.23655 12.509 7.66118 13.8002 9.96905L8.97959 4.99304C9.26288 4.48707 9.5314 4.00688 9.77902 3.56351C9.87736 3.38837 10.1219 3.38837 10.2203 3.56351V3.56456ZM2.69109 16.2358C2.95655 15.7629 3.32137 15.1144 3.40238 14.9651C4.17074 13.5913 5.20557 11.7415 6.27454 9.8302L4.50906 16.6307C3.87674 16.6307 3.33156 16.6307 2.91171 16.6307C2.71555 16.6307 2.59275 16.4114 2.69109 16.2363V16.2358ZM17.0871 16.6318C15.6151 16.6318 12.7572 16.6318 9.91965 16.6318L16.5083 14.8094C16.8537 15.4268 17.1304 15.9212 17.3077 16.2379C17.406 16.413 17.2832 16.6318 17.0876 16.6318H17.0871Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
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 { CopyIcon } from 'lucide-react';
|
||||
|
||||
export const ResetPasswordDialog = ({
|
||||
link,
|
||||
open,
|
||||
onCopy,
|
||||
onOpenChange,
|
||||
}: {
|
||||
link: string;
|
||||
open: boolean;
|
||||
onCopy: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">Account Recovery Link</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Please send this recovery link to the user and instruct them to
|
||||
complete it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-between items-center w-full space-x-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={link}
|
||||
placeholder="Please type email to confirm"
|
||||
className="placeholder:opacity-50 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
readOnly
|
||||
/>
|
||||
<Button type="button" onClick={onCopy} className="space-x-[10px]">
|
||||
<CopyIcon size={20} /> <span>Copy and Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import {
|
||||
useMutateQueryResource,
|
||||
useMutation,
|
||||
} from '@affine/core/hooks/use-mutation';
|
||||
import {
|
||||
addToAdminMutation,
|
||||
addToEarlyAccessMutation,
|
||||
createChangePasswordUrlMutation,
|
||||
createUserMutation,
|
||||
deleteUserMutation,
|
||||
EarlyAccessType,
|
||||
FeatureType,
|
||||
listUsersQuery,
|
||||
removeAdminMutation,
|
||||
removeEarlyAccessMutation,
|
||||
updateAccountMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const { trigger: createUser } = useMutation({
|
||||
mutation: createUserMutation,
|
||||
});
|
||||
|
||||
const { trigger: addToEarlyAccess } = useMutation({
|
||||
mutation: addToEarlyAccessMutation,
|
||||
});
|
||||
|
||||
const { trigger: addToAdmin } = useMutation({
|
||||
mutation: addToAdminMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const updateFeatures = useCallback(
|
||||
(email: string, features: FeatureType[]) => {
|
||||
const shouldAddToAdmin = features.includes(FeatureType.Admin);
|
||||
const shouldAddToAIEarlyAccess = features.includes(
|
||||
FeatureType.AIEarlyAccess
|
||||
);
|
||||
|
||||
return Promise.all([
|
||||
shouldAddToAdmin && addToAdmin({ email }),
|
||||
shouldAddToAIEarlyAccess &&
|
||||
addToEarlyAccess({ email, type: EarlyAccessType.AI }),
|
||||
]);
|
||||
},
|
||||
[addToAdmin, addToEarlyAccess]
|
||||
);
|
||||
|
||||
const create = useAsyncCallback(
|
||||
async ({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
features,
|
||||
callback,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
features: FeatureType[];
|
||||
callback?: () => void;
|
||||
}) => {
|
||||
await createUser({
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
await updateFeatures(email, features);
|
||||
await revalidate(listUsersQuery);
|
||||
toast('User created successfully');
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast(e.message);
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[createUser, revalidate, updateFeatures]
|
||||
);
|
||||
|
||||
return create;
|
||||
};
|
||||
|
||||
interface UpdateUserProps {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
features: FeatureType[];
|
||||
callback?: () => void;
|
||||
}
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const { trigger: updateAccount } = useMutation({
|
||||
mutation: updateAccountMutation,
|
||||
});
|
||||
|
||||
const { trigger: addToEarlyAccess } = useMutation({
|
||||
mutation: addToEarlyAccessMutation,
|
||||
});
|
||||
|
||||
const { trigger: removeEarlyAccess } = useMutation({
|
||||
mutation: removeEarlyAccessMutation,
|
||||
});
|
||||
|
||||
const { trigger: addToAdmin } = useMutation({
|
||||
mutation: addToAdminMutation,
|
||||
});
|
||||
|
||||
const { trigger: removeAdmin } = useMutation({
|
||||
mutation: removeAdminMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const updateFeatures = useCallback(
|
||||
({ email, features }: { email: string; features: FeatureType[] }) => {
|
||||
const shoutAddToAdmin = features.includes(FeatureType.Admin);
|
||||
const shoutAddToAIEarlyAccess = features.includes(
|
||||
FeatureType.AIEarlyAccess
|
||||
);
|
||||
|
||||
return Promise.all([
|
||||
shoutAddToAdmin ? addToAdmin({ email }) : removeAdmin({ email }),
|
||||
shoutAddToAIEarlyAccess
|
||||
? addToEarlyAccess({ email, type: EarlyAccessType.AI })
|
||||
: removeEarlyAccess({ email, type: EarlyAccessType.AI }),
|
||||
]);
|
||||
},
|
||||
[addToAdmin, addToEarlyAccess, removeAdmin, removeEarlyAccess]
|
||||
);
|
||||
|
||||
const update = useAsyncCallback(
|
||||
async ({ userId, name, email, features, callback }: UpdateUserProps) => {
|
||||
updateAccount({
|
||||
id: userId,
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
await updateFeatures({ email, features });
|
||||
await revalidate(listUsersQuery);
|
||||
toast('Account updated successfully');
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to update account: ' + e.message);
|
||||
});
|
||||
},
|
||||
[revalidate, updateAccount, updateFeatures]
|
||||
);
|
||||
|
||||
return update;
|
||||
};
|
||||
|
||||
export const useResetUserPassword = () => {
|
||||
const [resetPasswordLink, setResetPasswordLink] = useState('');
|
||||
const { trigger: resetPassword } = useMutation({
|
||||
mutation: createChangePasswordUrlMutation,
|
||||
});
|
||||
|
||||
const onResetPassword = useCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
setResetPasswordLink('');
|
||||
resetPassword({
|
||||
userId: id,
|
||||
callbackUrl: '/auth/changePassword?isClient=false',
|
||||
})
|
||||
.then(res => {
|
||||
setResetPasswordLink(res.createChangePasswordUrl);
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to reset password: ' + e.message);
|
||||
});
|
||||
},
|
||||
[resetPassword]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
resetPasswordLink,
|
||||
onResetPassword,
|
||||
};
|
||||
}, [onResetPassword, resetPasswordLink]);
|
||||
};
|
||||
|
||||
export const useDeleteUser = () => {
|
||||
const { trigger: deleteUserById } = useMutation({
|
||||
mutation: deleteUserMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const deleteById = useAsyncCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
await deleteUserById({ id })
|
||||
.then(async () => {
|
||||
await revalidate(listUsersQuery);
|
||||
toast('User deleted successfully');
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to delete user: ' + e.message);
|
||||
});
|
||||
},
|
||||
[deleteUserById, revalidate]
|
||||
);
|
||||
|
||||
return deleteById;
|
||||
};
|
||||
|
||||
export const useUserManagement = () => {
|
||||
const createUser = useCreateUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
resetPasswordLink,
|
||||
onResetPassword,
|
||||
};
|
||||
}, [createUser, deleteUser, onResetPassword, resetPasswordLink, updateUser]);
|
||||
};
|
||||
47
packages/frontend/admin/src/modules/accounts/index.tsx
Normal file
47
packages/frontend/admin/src/modules/accounts/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { listUsersQuery } from '@affine/graphql';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Layout } from '../layout';
|
||||
import { columns } from './components/columns';
|
||||
import { DataTable } from './components/data-table';
|
||||
|
||||
export function Accounts() {
|
||||
return <Layout content={<AccountPage />} />;
|
||||
}
|
||||
|
||||
export function AccountPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const {
|
||||
data: { users },
|
||||
} = useQuery({
|
||||
query: listUsersQuery,
|
||||
variables: {
|
||||
filter: {
|
||||
first: pagination.pageSize,
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className=" h-screen flex-1 space-y-1 flex-col flex">
|
||||
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9">
|
||||
<div className="text-base font-medium">Accounts</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { Accounts as Component };
|
||||
34
packages/frontend/admin/src/modules/accounts/schema.ts
Normal file
34
packages/frontend/admin/src/modules/accounts/schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import { z } from 'zod';
|
||||
|
||||
const featureTypeValues = Object.values(FeatureType) as [
|
||||
FeatureType,
|
||||
...FeatureType[],
|
||||
];
|
||||
const featureTypeEnum = z.enum(featureTypeValues);
|
||||
|
||||
export const userSchema = z.object({
|
||||
__typename: z.literal('UserType').optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
features: z.array(featureTypeEnum),
|
||||
hasPassword: z.boolean().nullable(),
|
||||
emailVerified: z.boolean(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
quota: z
|
||||
.object({
|
||||
__typename: z.literal('UserQuota').optional(),
|
||||
humanReadable: z.object({
|
||||
__typename: z.literal('UserQuotaHumanReadable').optional(),
|
||||
blobLimit: z.string(),
|
||||
historyPeriod: z.string(),
|
||||
memberLimit: z.string(),
|
||||
name: z.string(),
|
||||
storageQuota: z.string(),
|
||||
}),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
Reference in New Issue
Block a user