mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
fix(admin): organize admin panel (#7840)
This commit is contained in:
@@ -1,134 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import type { UserType } from '@affine/graphql';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { User } from '../schema';
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
const StatusItem = ({
|
||||
@@ -51,7 +51,7 @@ const StatusItem = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export const columns: ColumnDef<User>[] = [
|
||||
export const columns: ColumnDef<UserType>[] = [
|
||||
{
|
||||
accessorKey: 'info',
|
||||
cell: ({ row }) => (
|
||||
@@ -88,13 +88,13 @@ export const columns: ColumnDef<User>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'property',
|
||||
cell: ({ row }) => (
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<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 justify-end opacity-25">{user.id}</div>
|
||||
<div className="flex gap-3 items-center justify-end">
|
||||
<StatusItem
|
||||
condition={row.original.hasPassword}
|
||||
condition={user.hasPassword}
|
||||
IconTrue={<LockIcon size={10} />}
|
||||
IconFalse={<UnlockIcon size={10} />}
|
||||
textTrue="Password Set"
|
||||
@@ -102,7 +102,7 @@ export const columns: ColumnDef<User>[] = [
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
condition={row.original.emailVerified}
|
||||
condition={user.emailVerified}
|
||||
IconTrue={<MailIcon size={10} />}
|
||||
IconFalse={<MailWarningIcon size={10} />}
|
||||
textTrue="Email Verified"
|
||||
@@ -110,7 +110,7 @@ export const columns: ColumnDef<User>[] = [
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableRowActions row={row} />
|
||||
<DataTableRowActions user={user} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import {
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -17,29 +16,26 @@ import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import { userSchema } from '../schema';
|
||||
import type { UserType } 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';
|
||||
import { useDeleteUser, useResetUserPassword } from './use-user-management';
|
||||
import { UpdateUserForm } from './user-form';
|
||||
|
||||
interface DataTableRowActionsProps<TData> {
|
||||
row: Row<TData>;
|
||||
interface DataTableRowActionsProps {
|
||||
user: UserType;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<TData>({
|
||||
row,
|
||||
}: DataTableRowActionsProps<TData>) {
|
||||
export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
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 deleteUser = useDeleteUser();
|
||||
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
|
||||
|
||||
const openResetPasswordDialog = useCallback(() => {
|
||||
onResetPassword(user.id, () => setResetPasswordDialogOpen(true));
|
||||
@@ -82,8 +78,9 @@ export function DataTableRowActions<TData>({
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(
|
||||
<EditPanel
|
||||
<UpdateUserForm
|
||||
user={user}
|
||||
onComplete={closePanel}
|
||||
onResetPassword={openResetPasswordDialog}
|
||||
onDeleteAccount={openDeleteDialog}
|
||||
/>
|
||||
@@ -96,6 +93,7 @@ export function DataTableRowActions<TData>({
|
||||
openPanel();
|
||||
}
|
||||
}, [
|
||||
closePanel,
|
||||
discardDialogOpen,
|
||||
handleDiscardChangesCancel,
|
||||
isOpen,
|
||||
|
||||
@@ -7,8 +7,8 @@ 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';
|
||||
import { CreateUserForm } from './user-form';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
data: TData[];
|
||||
@@ -38,17 +38,18 @@ export function DataTableToolbar<TData>({
|
||||
const [value, setValue] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const debouncedValue = useDebouncedValue(value, 500);
|
||||
const { setRightPanelContent, openPanel, isOpen } = useRightPanel();
|
||||
const { setRightPanelContent, openPanel, closePanel, isOpen } =
|
||||
useRightPanel();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(<CreateUserPanel />);
|
||||
setRightPanelContent(<CreateUserForm onComplete={closePanel} />);
|
||||
if (dialogOpen) {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [setRightPanelContent, dialogOpen, isOpen, openPanel]);
|
||||
}, [setRightPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
|
||||
|
||||
const result = useQuery({
|
||||
query: getUserByEmailQuery,
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,161 +4,103 @@ import {
|
||||
useMutation,
|
||||
} from '@affine/core/hooks/use-mutation';
|
||||
import {
|
||||
addToAdminMutation,
|
||||
addToEarlyAccessMutation,
|
||||
createChangePasswordUrlMutation,
|
||||
createUserMutation,
|
||||
deleteUserMutation,
|
||||
EarlyAccessType,
|
||||
FeatureType,
|
||||
listUsersQuery,
|
||||
removeAdminMutation,
|
||||
removeEarlyAccessMutation,
|
||||
updateAccountFeaturesMutation,
|
||||
updateAccountMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { UserInput } from '../schema';
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const { trigger: createUser } = useMutation({
|
||||
const {
|
||||
trigger: createAccount,
|
||||
isMutating: creating,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutation: createUserMutation,
|
||||
});
|
||||
|
||||
const { trigger: addToEarlyAccess } = useMutation({
|
||||
mutation: addToEarlyAccessMutation,
|
||||
});
|
||||
|
||||
const { trigger: addToAdmin } = useMutation({
|
||||
mutation: addToAdminMutation,
|
||||
const { trigger: updateAccountFeatures } = useMutation({
|
||||
mutation: updateAccountFeaturesMutation,
|
||||
});
|
||||
|
||||
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);
|
||||
async ({ name, email, features }: UserInput) => {
|
||||
try {
|
||||
const account = await createAccount({
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
await updateAccountFeatures({
|
||||
userId: account.createUser.id,
|
||||
features,
|
||||
});
|
||||
await revalidate(listUsersQuery);
|
||||
toast('Account updated successfully');
|
||||
} catch (e) {
|
||||
toast.error('Failed to update account: ' + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[createUser, revalidate, updateFeatures]
|
||||
[createAccount, revalidate]
|
||||
);
|
||||
|
||||
return create;
|
||||
return { creating: creating || !!error, create };
|
||||
};
|
||||
|
||||
interface UpdateUserProps {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
features: FeatureType[];
|
||||
callback?: () => void;
|
||||
}
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const { trigger: updateAccount } = useMutation({
|
||||
const {
|
||||
trigger: updateAccount,
|
||||
isMutating: updating,
|
||||
error,
|
||||
} = 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 { trigger: updateAccountFeatures } = useMutation({
|
||||
mutation: updateAccountFeaturesMutation,
|
||||
});
|
||||
|
||||
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);
|
||||
async ({
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
features,
|
||||
}: UserInput & { userId: string }) => {
|
||||
try {
|
||||
await updateAccount({
|
||||
id: userId,
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
});
|
||||
await updateAccountFeatures({
|
||||
userId,
|
||||
features,
|
||||
});
|
||||
await revalidate(listUsersQuery);
|
||||
toast('Account updated successfully');
|
||||
} catch (e) {
|
||||
toast.error('Failed to update account: ' + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[revalidate, updateAccount, updateFeatures]
|
||||
[revalidate, updateAccount]
|
||||
);
|
||||
|
||||
return update;
|
||||
return { updating: updating || !!error, update };
|
||||
};
|
||||
|
||||
export const useResetUserPassword = () => {
|
||||
@@ -217,20 +159,3 @@ export const useDeleteUser = () => {
|
||||
|
||||
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]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
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 { CheckIcon, ChevronRightIcon, XIcon } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useServerConfig } from '../../common';
|
||||
import type { UserInput, UserType } from '../schema';
|
||||
import { useCreateUser, useUpdateUser } from './use-user-management';
|
||||
|
||||
type UserFormProps = {
|
||||
title: string;
|
||||
defaultValue?: Partial<UserInput>;
|
||||
onClose: () => void;
|
||||
onConfirm: (user: UserInput) => void;
|
||||
onValidate: (user: Partial<UserInput>) => boolean;
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
function UserForm({
|
||||
title,
|
||||
defaultValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onValidate,
|
||||
actions,
|
||||
}: UserFormProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
const [changes, setChanges] = useState<Partial<UserInput>>({
|
||||
features: defaultValue?.features ?? [],
|
||||
});
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof UserInput>(
|
||||
field: K,
|
||||
value: UserInput[K] | ((prev: UserInput[K] | undefined) => UserInput[K])
|
||||
) => {
|
||||
setChanges(changes => ({
|
||||
...changes,
|
||||
[field]:
|
||||
typeof value === 'function' ? value(changes[field] as any) : value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return onValidate(changes);
|
||||
}, [onValidate, changes]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error checked
|
||||
onConfirm(changes);
|
||||
}, [canSave, changes, onConfirm]);
|
||||
|
||||
const onFeatureChanged = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
setField('features', (features = []) => {
|
||||
if (checked) {
|
||||
return [...features, feature];
|
||||
}
|
||||
return features.filter(f => f !== feature);
|
||||
});
|
||||
},
|
||||
[setField]
|
||||
);
|
||||
|
||||
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={onClose}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</Button>
|
||||
<span className="text-base font-medium">{title}</span>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<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">
|
||||
<InputItem
|
||||
label="Name"
|
||||
field="name"
|
||||
value={changes.name ?? defaultValue?.name}
|
||||
onChange={setField}
|
||||
/>
|
||||
<Separator />
|
||||
<InputItem
|
||||
label="Email"
|
||||
field="email"
|
||||
value={changes.email ?? defaultValue?.email}
|
||||
onChange={setField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
{serverConfig.availableUserFeatures.map((feature, i) => (
|
||||
<div key={feature}>
|
||||
<ToggleItem
|
||||
name={feature}
|
||||
checked={(
|
||||
changes.features ??
|
||||
defaultValue?.features ??
|
||||
[]
|
||||
).includes(feature)}
|
||||
onChange={onFeatureChanged}
|
||||
/>
|
||||
{i < serverConfig.availableUserFeatures.length - 1 && (
|
||||
<Separator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{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 px-4 py-3">
|
||||
<span>{name}</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function InputItem({
|
||||
label,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
field: keyof UserInput;
|
||||
value?: string;
|
||||
onChange: (field: keyof UserInput, value: string) => void;
|
||||
}) {
|
||||
const onValueChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(field, e.target.value);
|
||||
},
|
||||
[field, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
className="py-2 px-3 text-base font-normal"
|
||||
defaultValue={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validateCreateUser = (user: Partial<UserInput>) => {
|
||||
return !!user.name && !!user.email && !!user.features;
|
||||
};
|
||||
|
||||
const validateUpdateUser = (user: Partial<UserInput>) => {
|
||||
return !!user.name || !!user.email;
|
||||
};
|
||||
|
||||
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
const { create, creating } = useCreateUser();
|
||||
useEffect(() => {
|
||||
if (creating) {
|
||||
return () => {
|
||||
onComplete();
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}, [creating, onComplete]);
|
||||
return (
|
||||
<UserForm
|
||||
title="Create User"
|
||||
onClose={onComplete}
|
||||
onConfirm={create}
|
||||
onValidate={validateCreateUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateUserForm({
|
||||
user,
|
||||
onResetPassword,
|
||||
onDeleteAccount,
|
||||
onComplete,
|
||||
}: {
|
||||
user: UserType;
|
||||
onResetPassword: () => void;
|
||||
onDeleteAccount: () => void;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { update, updating } = useUpdateUser();
|
||||
|
||||
const onUpdateUser = useCallback(
|
||||
(updates: UserInput) => {
|
||||
update({
|
||||
...updates,
|
||||
userId: user.id,
|
||||
});
|
||||
},
|
||||
[user, update]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updating) {
|
||||
return () => {
|
||||
onComplete();
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [updating, onComplete]);
|
||||
|
||||
return (
|
||||
<UserForm
|
||||
title="Update User"
|
||||
defaultValue={user}
|
||||
onClose={onComplete}
|
||||
onConfirm={onUpdateUser}
|
||||
onValidate={validateUpdateUser}
|
||||
actions={
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export function AccountPage() {
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
// @ts-expect-error do not complains
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import { z } from 'zod';
|
||||
import type { FeatureType, ListUsersQuery } from '@affine/graphql';
|
||||
|
||||
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>;
|
||||
export type UserType = ListUsersQuery['users'][0];
|
||||
export type UserInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
features: FeatureType[];
|
||||
};
|
||||
|
||||
@@ -2,31 +2,21 @@ 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 { useMutateQueryResource } from '@affine/core/hooks/use-mutation';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import {
|
||||
FeatureType,
|
||||
getCurrentUserFeaturesQuery,
|
||||
getUserFeaturesQuery,
|
||||
serverConfigQuery,
|
||||
} from '@affine/graphql';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useCurrentUser, useServerConfig } from '../common';
|
||||
import logo from './logo.svg';
|
||||
|
||||
export function Auth() {
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
|
||||
const {
|
||||
data: { serverConfig },
|
||||
} = useQuery({
|
||||
query: serverConfigQuery,
|
||||
});
|
||||
const currentUser = useCurrentUser();
|
||||
const serverConfig = useServerConfig();
|
||||
const revalidate = useMutateQueryResource();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
21
packages/frontend/admin/src/modules/common.ts
Normal file
21
packages/frontend/admin/src/modules/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQueryImmutable } from '@affine/core/hooks/use-query';
|
||||
import {
|
||||
adminServerConfigQuery,
|
||||
getCurrentUserFeaturesQuery,
|
||||
} from '@affine/graphql';
|
||||
|
||||
export const useServerConfig = () => {
|
||||
const { data } = useQueryImmutable({
|
||||
query: adminServerConfigQuery,
|
||||
});
|
||||
|
||||
return data.serverConfig;
|
||||
};
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const { data } = useQueryImmutable({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
|
||||
return data.currentUser;
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@affine/admin/components/ui/card';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { useQueryImmutable } from '@affine/core/hooks/use-query';
|
||||
import { getServerServiceConfigsQuery } from '@affine/graphql';
|
||||
|
||||
import { Layout } from '../layout';
|
||||
@@ -171,7 +171,7 @@ const MailerCard = ({ mailerConfig }: { mailerConfig?: MailerConfig }) => {
|
||||
};
|
||||
|
||||
export function ServerServiceConfig() {
|
||||
const { data } = useQuery({
|
||||
const { data } = useQueryImmutable({
|
||||
query: getServerServiceConfigsQuery,
|
||||
});
|
||||
const server = data.serverServiceConfigs.find(
|
||||
|
||||
@@ -7,11 +7,7 @@ import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import {
|
||||
FeatureType,
|
||||
getCurrentUserFeaturesQuery,
|
||||
serverConfigQuery,
|
||||
} from '@affine/graphql';
|
||||
import { FeatureType, getCurrentUserFeaturesQuery } from '@affine/graphql';
|
||||
import { AlignJustifyIcon } from 'lucide-react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import {
|
||||
@@ -36,6 +32,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from '../components/ui/sheet';
|
||||
import { Logo } from './accounts/components/logo';
|
||||
import { useServerConfig } from './common';
|
||||
import { NavContext } from './nav/context';
|
||||
import { Nav } from './nav/nav';
|
||||
|
||||
@@ -85,6 +82,13 @@ export function useMediaQuery(query: string) {
|
||||
}
|
||||
|
||||
export function Layout({ content }: LayoutProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
|
||||
const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const rightPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
@@ -122,16 +126,6 @@ export function Layout({ content }: LayoutProps) {
|
||||
[closePanel, openPanel]
|
||||
);
|
||||
|
||||
const {
|
||||
data: { serverConfig },
|
||||
} = useQuery({
|
||||
query: serverConfigQuery,
|
||||
});
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -12,29 +12,17 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import {
|
||||
FeatureType,
|
||||
getCurrentUserFeaturesQuery,
|
||||
serverConfigQuery,
|
||||
} from '@affine/graphql';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import { CircleUser, MoreVertical } from 'lucide-react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function UserDropdown() {
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
import { useCurrentUser, useServerConfig } from '../common';
|
||||
|
||||
const {
|
||||
data: { serverConfig },
|
||||
} = useQuery({
|
||||
query: serverConfigQuery,
|
||||
});
|
||||
export function UserDropdown() {
|
||||
const currentUser = useCurrentUser();
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from '@affine/admin/components/ui/carousel';
|
||||
import { validateEmailAndPassword } from '@affine/admin/utils';
|
||||
import { useMutateQueryResource } from '@affine/core/hooks/use-mutation';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { serverConfigQuery } from '@affine/graphql';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useServerConfig } from '../common';
|
||||
import { CreateAdmin } from './create-admin';
|
||||
|
||||
export enum CarouselSteps {
|
||||
@@ -72,10 +72,8 @@ export const Form = () => {
|
||||
const [invalidEmail, setInvalidEmail] = useState(false);
|
||||
const [invalidPassword, setInvalidPassword] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
query: serverConfigQuery,
|
||||
});
|
||||
const passwordLimits = data.serverConfig.credentialsRequirement.password;
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
|
||||
const isCreateAdminStep = current - 1 === CarouselSteps.CreateAdmin;
|
||||
|
||||
@@ -95,7 +93,7 @@ export const Form = () => {
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api, data.serverConfig.initialized, navigate]);
|
||||
}, [api, serverConfig.initialized, navigate]);
|
||||
|
||||
const createAdmin = useCallback(async () => {
|
||||
try {
|
||||
@@ -170,14 +168,14 @@ export const Form = () => {
|
||||
|
||||
const onPrevious = useCallback(() => {
|
||||
if (current === count) {
|
||||
if (data.serverConfig.initialized === true) {
|
||||
if (serverConfig.initialized === true) {
|
||||
return navigate('/admin', { replace: true });
|
||||
}
|
||||
toast.error('Goto Admin Panel failed, please try again.');
|
||||
return;
|
||||
}
|
||||
api?.scrollPrev();
|
||||
}, [api, count, current, data.serverConfig.initialized, navigate]);
|
||||
}, [api, count, current, serverConfig.initialized, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between h-full w-full lg:pl-36 max-lg:items-center ">
|
||||
|
||||
Reference in New Issue
Block a user