feat(admin): add ban user to admin panel (#10780)

This commit is contained in:
JimmFly
2025-03-13 18:37:21 +08:00
committed by forehalo
parent d24ced3dbd
commit e96302ccb2
12 changed files with 354 additions and 23 deletions

View File

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

View File

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

View File

@@ -65,6 +65,7 @@ export const DeleteAccountDialog = ({
onClick={onDelete}
size="sm"
variant="destructive"
disabled={input !== email}
>
Delete
</Button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
mutation disableUser($id: String!) {
banUser(id: $id) {
email
disabled
}
}

View File

@@ -0,0 +1,6 @@
mutation enableUser($id: String!) {
enableUser(id: $id) {
email
disabled
}
}

View File

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

View File

@@ -3,6 +3,7 @@ query listUsers($filter: ListUserInput!) {
id
name
email
disabled
features
hasPassword
emailVerified

View File

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