mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat(admin): add ban user to admin panel (#10780)
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
import type { UserType } from '@affine/graphql';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
LockIcon,
|
||||
@@ -64,18 +65,31 @@ export const columns: ColumnDef<UserType>[] = [
|
||||
</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>{' '}
|
||||
<span>{row.original.name}</span>
|
||||
{row.original.features.includes(FeatureType.Admin) && (
|
||||
<span
|
||||
className="rounded p-1 text-xs"
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border"
|
||||
style={{
|
||||
backgroundColor: 'rgba(30, 150, 235, 0.20)',
|
||||
color: 'rgba(30, 150, 235, 1)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/blue'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
{row.original.disabled && (
|
||||
<span
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border"
|
||||
style={{
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/white'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-medium opacity-50 max-w-full overflow-hidden">
|
||||
{row.original.email}
|
||||
|
||||
@@ -7,20 +7,28 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import {
|
||||
AccountBanIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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 { useDeleteUser, useResetUserPassword } from './use-user-management';
|
||||
import {
|
||||
useDeleteUser,
|
||||
useDisableUser,
|
||||
useEnableUser,
|
||||
useResetUserPassword,
|
||||
} from './use-user-management';
|
||||
import { UpdateUserForm } from './user-form';
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
@@ -30,10 +38,14 @@ interface DataTableRowActionsProps {
|
||||
export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||||
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
||||
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const { openPanel, isOpen, closePanel, setPanelContent } = useRightPanel();
|
||||
|
||||
const deleteUser = useDeleteUser();
|
||||
const disableUser = useDisableUser();
|
||||
const enableUser = useEnableUser();
|
||||
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
|
||||
|
||||
const openResetPasswordDialog = useCallback(() => {
|
||||
@@ -56,25 +68,56 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
});
|
||||
}, [resetPasswordLink]);
|
||||
|
||||
const onDeleting = useCallback(() => {
|
||||
const handleDeleting = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
const handleDisabling = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setDisableDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
const handleEnabling = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setEnableDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteUser(user.id, onDeleting);
|
||||
}, [deleteUser, onDeleting, user.id]);
|
||||
deleteUser(user.id, handleDeleting);
|
||||
}, [deleteUser, handleDeleting, user.id]);
|
||||
const handleDisable = useCallback(() => {
|
||||
disableUser(user.id, handleDisabling);
|
||||
}, [disableUser, handleDisabling, user.id]);
|
||||
const handleEnable = useCallback(() => {
|
||||
enableUser(user.id, handleEnabling);
|
||||
}, [enableUser, handleEnabling, user.id]);
|
||||
|
||||
const openDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const openDisableDialog = useCallback(() => {
|
||||
setDisableDialogOpen(true);
|
||||
}, []);
|
||||
const closeDisableDialog = useCallback(() => {
|
||||
setDisableDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const openEnableDialog = useCallback(() => {
|
||||
setEnableDialogOpen(true);
|
||||
}, []);
|
||||
const closeEnableDialog = useCallback(() => {
|
||||
setEnableDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChangesCancel = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
}, []);
|
||||
@@ -122,7 +165,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
variant="ghost"
|
||||
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<MoreVerticalIcon size={20} />
|
||||
<MoreHorizontalIcon fontSize={20} />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -135,21 +178,36 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon size={16} /> Reset Password
|
||||
<LockIcon fontSize={20} /> Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
>
|
||||
<SettingsIcon size={16} /> Edit
|
||||
<EditIcon fontSize={20} /> Edit
|
||||
</DropdownMenuItem>
|
||||
|
||||
{user.disabled && (
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
onSelect={openEnableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} /> Enable Email
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{!user.disabled && (
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDisableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} /> Disable & Delete data
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<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
|
||||
<DeleteIcon fontSize={20} /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -160,6 +218,20 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<DisableAccountDialog
|
||||
email={user.email}
|
||||
open={disableDialogOpen}
|
||||
onClose={closeDisableDialog}
|
||||
onOpenChange={setDisableDialogOpen}
|
||||
onDisable={handleDisable}
|
||||
/>
|
||||
<EnableAccountDialog
|
||||
email={user.email}
|
||||
open={enableDialogOpen}
|
||||
onClose={closeEnableDialog}
|
||||
onOpenChange={setEnableDialogOpen}
|
||||
onConfirm={handleEnable}
|
||||
/>
|
||||
<ResetPasswordDialog
|
||||
link={resetPasswordLink}
|
||||
open={resetPasswordDialogOpen}
|
||||
|
||||
@@ -65,6 +65,7 @@ export const DeleteAccountDialog = ({
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={input !== email}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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 DisableAccountDialog = ({
|
||||
email,
|
||||
open,
|
||||
onClose,
|
||||
onDisable,
|
||||
onOpenChange,
|
||||
}: {
|
||||
email: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
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"
|
||||
/>
|
||||
<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={onDisable}
|
||||
disabled={input !== email}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
|
||||
export const EnableAccountDialog = ({
|
||||
open,
|
||||
email,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
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>
|
||||
<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="default">
|
||||
<span>Enable</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
createChangePasswordUrlMutation,
|
||||
createUserMutation,
|
||||
deleteUserMutation,
|
||||
disableUserMutation,
|
||||
enableUserMutation,
|
||||
getUsersCountQuery,
|
||||
listUsersQuery,
|
||||
updateAccountFeaturesMutation,
|
||||
@@ -162,6 +164,55 @@ export const useDeleteUser = () => {
|
||||
return deleteById;
|
||||
};
|
||||
|
||||
export const useEnableUser = () => {
|
||||
const { trigger: enableUserById } = useMutation({
|
||||
mutation: enableUserMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const enableById = useAsyncCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
await enableUserById({ id })
|
||||
.then(async ({ enableUser }) => {
|
||||
await revalidate(listUsersQuery);
|
||||
toast(`User ${enableUser.email} enabled successfully`);
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to enable user: ' + e.message);
|
||||
});
|
||||
},
|
||||
[enableUserById, revalidate]
|
||||
);
|
||||
|
||||
return enableById;
|
||||
};
|
||||
export const useDisableUser = () => {
|
||||
const { trigger: disableUserById } = useMutation({
|
||||
mutation: disableUserMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const disableById = useAsyncCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
await disableUserById({ id })
|
||||
.then(async ({ banUser }) => {
|
||||
await revalidate(listUsersQuery);
|
||||
toast(`User ${banUser.email} disabled successfully`);
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to disable user: ' + e.message);
|
||||
});
|
||||
},
|
||||
[disableUserById, revalidate]
|
||||
);
|
||||
|
||||
return disableById;
|
||||
};
|
||||
|
||||
export const useUserCount = () => {
|
||||
const {
|
||||
data: { usersCount },
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import { CircleUser, MoreVertical } from 'lucide-react';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { CircleUser } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -79,10 +81,11 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
|
||||
<span className="text-sm">{currentUser?.email.split('@')[0]}</span>
|
||||
)}
|
||||
<span
|
||||
className="rounded p-1 text-xs"
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border"
|
||||
style={{
|
||||
backgroundColor: 'rgba(30, 150, 235, 0.20)',
|
||||
color: 'rgba(30, 150, 235, 1)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/blue'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
@@ -91,7 +94,7 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="ml-2 p-1 h-6">
|
||||
<MoreVertical size={20} />
|
||||
<MoreVerticalIcon fontSize={20} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
|
||||
6
packages/frontend/graphql/src/graphql/disable-user.gql
Normal file
6
packages/frontend/graphql/src/graphql/disable-user.gql
Normal file
@@ -0,0 +1,6 @@
|
||||
mutation disableUser($id: String!) {
|
||||
banUser(id: $id) {
|
||||
email
|
||||
disabled
|
||||
}
|
||||
}
|
||||
6
packages/frontend/graphql/src/graphql/enable-user.gql
Normal file
6
packages/frontend/graphql/src/graphql/enable-user.gql
Normal file
@@ -0,0 +1,6 @@
|
||||
mutation enableUser($id: String!) {
|
||||
enableUser(id: $id) {
|
||||
email
|
||||
disabled
|
||||
}
|
||||
}
|
||||
@@ -439,6 +439,17 @@ export const deleteWorkspaceMutation = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const disableUserMutation = {
|
||||
id: 'disableUserMutation' as const,
|
||||
op: 'disableUser',
|
||||
query: `mutation disableUser($id: String!) {
|
||||
banUser(id: $id) {
|
||||
email
|
||||
disabled
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getDocRolePermissionsQuery = {
|
||||
id: 'getDocRolePermissionsQuery' as const,
|
||||
op: 'getDocRolePermissions',
|
||||
@@ -465,6 +476,17 @@ export const getDocRolePermissionsQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const enableUserMutation = {
|
||||
id: 'enableUserMutation' as const,
|
||||
op: 'enableUser',
|
||||
query: `mutation enableUser($id: String!) {
|
||||
enableUser(id: $id) {
|
||||
email
|
||||
disabled
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const generateLicenseKeyMutation = {
|
||||
id: 'generateLicenseKeyMutation' as const,
|
||||
op: 'generateLicenseKey',
|
||||
@@ -970,6 +992,7 @@ export const listUsersQuery = {
|
||||
id
|
||||
name
|
||||
email
|
||||
disabled
|
||||
features
|
||||
hasPassword
|
||||
emailVerified
|
||||
|
||||
@@ -3,6 +3,7 @@ query listUsers($filter: ListUserInput!) {
|
||||
id
|
||||
name
|
||||
email
|
||||
disabled
|
||||
features
|
||||
hasPassword
|
||||
emailVerified
|
||||
|
||||
@@ -2597,6 +2597,15 @@ export type DeleteWorkspaceMutation = {
|
||||
deleteWorkspace: boolean;
|
||||
};
|
||||
|
||||
export type DisableUserMutationVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type DisableUserMutation = {
|
||||
__typename?: 'Mutation';
|
||||
banUser: { __typename?: 'UserType'; email: string; disabled: boolean };
|
||||
};
|
||||
|
||||
export type GetDocRolePermissionsQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
@@ -2628,6 +2637,15 @@ export type GetDocRolePermissionsQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type EnableUserMutationVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type EnableUserMutation = {
|
||||
__typename?: 'Mutation';
|
||||
enableUser: { __typename?: 'UserType'; email: string; disabled: boolean };
|
||||
};
|
||||
|
||||
export type CredentialsRequirementsFragment = {
|
||||
__typename?: 'CredentialsRequirementType';
|
||||
password: {
|
||||
@@ -3195,6 +3213,7 @@ export type ListUsersQuery = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
disabled: boolean;
|
||||
features: Array<FeatureType>;
|
||||
hasPassword: boolean | null;
|
||||
emailVerified: boolean;
|
||||
@@ -4152,6 +4171,16 @@ export type Mutations =
|
||||
variables: DeleteWorkspaceMutationVariables;
|
||||
response: DeleteWorkspaceMutation;
|
||||
}
|
||||
| {
|
||||
name: 'disableUserMutation';
|
||||
variables: DisableUserMutationVariables;
|
||||
response: DisableUserMutation;
|
||||
}
|
||||
| {
|
||||
name: 'enableUserMutation';
|
||||
variables: EnableUserMutationVariables;
|
||||
response: EnableUserMutation;
|
||||
}
|
||||
| {
|
||||
name: 'generateLicenseKeyMutation';
|
||||
variables: GenerateLicenseKeyMutationVariables;
|
||||
|
||||
Reference in New Issue
Block a user