feat(admin): add self-host setup and user management page (#7537)

This commit is contained in:
JimmFly
2024-08-13 14:11:03 +08:00
committed by GitHub
parent dc519348c5
commit ccf225c8f9
47 changed files with 2793 additions and 551 deletions

View File

@@ -34,6 +34,7 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.1",
"@sentry/react": "^8.9.0",
"@tanstack/react-table": "^8.19.3",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.1.5",

View File

@@ -23,8 +23,8 @@ const Redirect = function Redirect() {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (!location.pathname.startsWith('/admin')) {
navigate('/admin', { replace: true });
if (!location.pathname.startsWith('/admin/accounts')) {
navigate('/admin/accounts', { replace: true });
}
}, [location, navigate]);
return null;
@@ -41,15 +41,19 @@ export const router = _createBrowserRouter(
children: [
{
path: '',
lazy: () => import('./modules/home'),
element: <Redirect />,
},
{
path: '/admin/accounts',
lazy: () => import('./modules/accounts'),
},
{
path: '/admin/auth',
lazy: () => import('./modules/auth'),
},
{
path: '/admin/users',
lazy: () => import('./modules/users'),
path: '/admin/setup',
lazy: () => import('./modules/setup'),
},
],
},

View File

@@ -52,23 +52,30 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContentProps & { withoutCloseButton?: boolean }
>(
(
{ side = 'right', className, children, withoutCloseButton, ...props },
ref
) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
{!withoutCloseButton && (
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 };

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

View File

@@ -1,304 +0,0 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { Badge } from '@affine/admin/components/ui/badge';
import { Button } from '@affine/admin/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@affine/admin/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import {
Activity,
ArrowUpRight,
CreditCard,
DollarSign,
Users,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { Nav } from '../nav';
export function Dashboard() {
return (
<Nav>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
<MainDashBoard />
</main>
</Nav>
);
}
function MainDashBoard() {
return (
<>
<div className="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
<Card x-chunk="dashboard-01-chunk-0">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$45,231.89</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
</CardContent>
</Card>
<Card x-chunk="dashboard-01-chunk-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Subscriptions</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground">
+180.1% from last month
</p>
</CardContent>
</Card>
<Card x-chunk="dashboard-01-chunk-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sales</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+12,234</div>
<p className="text-xs text-muted-foreground">
+19% from last month
</p>
</CardContent>
</Card>
<Card x-chunk="dashboard-01-chunk-3">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Now</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">
+201 since last hour
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:gap-8 lg:grid-cols-2 xl:grid-cols-3">
<Card className="xl:col-span-2" x-chunk="dashboard-01-chunk-4">
<CardHeader className="flex flex-row items-center">
<div className="grid gap-2">
<CardTitle>Transactions</CardTitle>
<CardDescription>
Recent transactions from your store.
</CardDescription>
</div>
<Button asChild size="sm" className="ml-auto gap-1">
<Link to="/">
View All
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead className="hidden xl:table-column">Type</TableHead>
<TableHead className="hidden xl:table-column">
Status
</TableHead>
<TableHead className="hidden xl:table-column">Date</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<div className="font-medium">Liam Johnson</div>
<div className="hidden text-sm text-muted-foreground md:inline">
liam@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">Sale</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-23
</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Olivia Smith</div>
<div className="hidden text-sm text-muted-foreground md:inline">
olivia@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">
Refund
</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Declined
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-24
</TableCell>
<TableCell className="text-right">$150.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Noah Williams</div>
<div className="hidden text-sm text-muted-foreground md:inline">
noah@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">
Subscription
</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-25
</TableCell>
<TableCell className="text-right">$350.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Emma Brown</div>
<div className="hidden text-sm text-muted-foreground md:inline">
emma@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">Sale</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-26
</TableCell>
<TableCell className="text-right">$450.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Liam Johnson</div>
<div className="hidden text-sm text-muted-foreground md:inline">
liam@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">Sale</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-27
</TableCell>
<TableCell className="text-right">$550.00</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<Card x-chunk="dashboard-01-chunk-5">
<CardHeader>
<CardTitle>Recent Sales</CardTitle>
</CardHeader>
<CardContent className="grid gap-8">
<div className="flex items-center gap-4">
<Avatar className="hidden h-9 w-9 sm:flex">
<AvatarImage src="/avatars/01.png" alt="Avatar" />
<AvatarFallback>OM</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">
Olivia Martin
</p>
<p className="text-sm text-muted-foreground">
olivia.martin@email.com
</p>
</div>
<div className="ml-auto font-medium">+$1,999.00</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="hidden h-9 w-9 sm:flex">
<AvatarImage src="/avatars/02.png" alt="Avatar" />
<AvatarFallback>JL</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">Jackson Lee</p>
<p className="text-sm text-muted-foreground">
jackson.lee@email.com
</p>
</div>
<div className="ml-auto font-medium">+$39.00</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="hidden h-9 w-9 sm:flex">
<AvatarImage src="/avatars/03.png" alt="Avatar" />
<AvatarFallback>IN</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">
Isabella Nguyen
</p>
<p className="text-sm text-muted-foreground">
isabella.nguyen@email.com
</p>
</div>
<div className="ml-auto font-medium">+$299.00</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="hidden h-9 w-9 sm:flex">
<AvatarImage src="/avatars/04.png" alt="Avatar" />
<AvatarFallback>WK</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">William Kim</p>
<p className="text-sm text-muted-foreground">will@email.com</p>
</div>
<div className="ml-auto font-medium">+$99.00</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="hidden h-9 w-9 sm:flex">
<AvatarImage src="/avatars/05.png" alt="Avatar" />
<AvatarFallback>SD</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">Sofia Davis</p>
<p className="text-sm text-muted-foreground">
sofia.davis@email.com
</p>
</div>
<div className="ml-auto font-medium">+$39.00</div>
</div>
</CardContent>
</Card>
</div>
</>
);
}
export { Dashboard as Component };

View File

@@ -0,0 +1,290 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@affine/admin/components/ui/resizable';
import { Separator } from '@affine/admin/components/ui/separator';
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
import { cn } from '@affine/admin/utils';
import {
AlignJustifyIcon,
ClipboardList,
Cpu,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode, RefObject } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { Button } from '../components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '../components/ui/sheet';
import { Logo } from './accounts/components/logo';
import type { NavProp } from './nav/nav';
import { Nav } from './nav/nav';
interface LayoutProps {
content: ReactNode;
}
interface RightPanelContextType {
isOpen: boolean;
rightPanelContent: ReactNode;
setRightPanelContent: (content: ReactNode) => void;
togglePanel: () => void;
openPanel: () => void;
closePanel: () => void;
}
const RightPanelContext = createContext<RightPanelContextType | undefined>(
undefined
);
const navLinks: NavProp[] = [
{
title: 'Accounts',
icon: Users,
to: '/admin/accounts',
},
{
title: 'AI',
icon: Cpu,
to: '/admin/ai',
},
{
title: 'Config',
icon: ClipboardList,
to: '/admin/config',
},
{
title: 'Settings',
icon: Settings,
to: '/admin/settings',
},
];
export const useRightPanel = () => {
const context = useContext(RightPanelContext);
if (!context) {
throw new Error('useRightPanel must be used within a RightPanelProvider');
}
return context;
};
export function useMediaQuery(query: string) {
const [value, setValue] = useState(false);
useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches);
}
const result = matchMedia(query);
result.addEventListener('change', onChange);
setValue(result.matches);
return () => result.removeEventListener('change', onChange);
}, [query]);
return value;
}
export function Layout({ content }: LayoutProps) {
const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null);
const [open, setOpen] = useState(false);
const rightPanelRef = useRef<ImperativePanelHandle>(null);
const handleExpand = useCallback(() => {
if (rightPanelRef.current?.getSize() === 0) {
rightPanelRef.current?.resize(30);
}
setOpen(true);
}, [rightPanelRef]);
const handleCollapse = useCallback(() => {
if (rightPanelRef.current?.getSize() !== 0) {
rightPanelRef.current?.resize(0);
}
setOpen(false);
}, [rightPanelRef]);
const openPanel = useCallback(() => {
handleExpand();
rightPanelRef.current?.expand();
}, [handleExpand]);
const closePanel = useCallback(() => {
handleCollapse();
rightPanelRef.current?.collapse();
}, [handleCollapse]);
const togglePanel = useCallback(
() => (rightPanelRef.current?.isCollapsed() ? openPanel() : closePanel()),
[closePanel, openPanel]
);
const activeTab = useMemo(() => {
const path = window.location.pathname;
return (
navLinks.find(link => path.endsWith(link.title.toLocaleLowerCase()))
?.title || ''
);
}, []);
return (
<RightPanelContext.Provider
value={{
isOpen: open,
rightPanelContent,
setRightPanelContent,
togglePanel,
openPanel,
closePanel,
}}
>
<TooltipProvider delayDuration={0}>
<div className="flex">
<LeftPanel activeTab={activeTab} />
<ResizablePanelGroup direction="horizontal">
<ResizablePanel id="0" order={0} minSize={50}>
{content}
</ResizablePanel>
<RightPanel
rightPanelRef={rightPanelRef}
onExpand={handleExpand}
onCollapse={handleCollapse}
/>
</ResizablePanelGroup>
</div>
</TooltipProvider>
</RightPanelContext.Provider>
);
}
export const LeftPanel = ({ activeTab }: { activeTab: string }) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
if (isSmallScreen) {
return (
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
className="fixed top-[14px] left-6 p-0 h-5 w-5"
>
<AlignJustifyIcon />
</Button>
</SheetTrigger>
<SheetHeader className="hidden">
<SheetTitle>AFFiNE</SheetTitle>
<SheetDescription>
Admin panel for managing accounts, AI, config, and settings
</SheetDescription>
</SheetHeader>
<SheetContent side="left" className="p-0" withoutCloseButton>
<div className="flex flex-col w-full h-full">
<div
className={cn(
'flex h-[52px] items-center gap-2 px-4 text-base font-medium'
)}
>
<Logo />
AFFiNE
</div>
<Separator />
<Nav links={navLinks} activeTab={activeTab} />
</div>
</SheetContent>
</Sheet>
);
}
return (
<div className="flex flex-col min-w-52 max-w-sm border-r">
<div
className={cn(
'flex h-[52px] items-center gap-2 px-4 text-base font-medium'
)}
>
<Logo />
AFFiNE
</div>
<Separator />
<Nav links={navLinks} activeTab={activeTab} />
</div>
);
};
export const RightPanel = ({
rightPanelRef,
onExpand,
onCollapse,
}: {
rightPanelRef: RefObject<ImperativePanelHandle>;
onExpand: () => void;
onCollapse: () => void;
}) => {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { rightPanelContent, isOpen } = useRightPanel();
const onOpenChange = useCallback(
(open: boolean) => {
if (open) {
onExpand();
} else {
onCollapse();
}
},
[onExpand, onCollapse]
);
if (isSmallScreen) {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetHeader className="hidden">
<SheetTitle>Right Panel</SheetTitle>
<SheetDescription>
For displaying additional information
</SheetDescription>
</SheetHeader>
<SheetContent side="right" className="p-0" withoutCloseButton>
{rightPanelContent}
</SheetContent>
</Sheet>
);
}
return (
<>
<ResizableHandle />
<ResizablePanel
id="1"
order={1}
ref={rightPanelRef}
defaultSize={0}
maxSize={30}
collapsible={true}
collapsedSize={0}
onExpand={onExpand}
onCollapse={onCollapse}
>
{rightPanelContent}
</ResizablePanel>
</>
);
};

View File

@@ -0,0 +1,57 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import type { LucideIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import { UserDropdown } from './user-dropdown';
export interface NavProp {
title: string;
to: string;
label?: string;
icon: LucideIcon;
}
export function Nav({
links,
activeTab,
}: {
links: NavProp[];
activeTab: string;
}) {
return (
<div className="group flex flex-col gap-4 py-2 justify-between flex-grow">
<nav className="grid gap-1 px-2">
{links.map((link, index) => (
<Link
key={index}
to={link.to}
className={cn(
buttonVariants({
variant: activeTab === link.title ? 'default' : 'ghost',
size: 'sm',
}),
activeTab === link.title &&
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
'justify-start'
)}
>
<link.icon className="mr-2 h-4 w-4" />
{link.title}
{link.label && (
<span
className={cn(
'ml-auto',
activeTab === link.title && 'text-background dark:text-white'
)}
>
{link.label}
</span>
)}
</Link>
))}
</nav>
<UserDropdown />
</div>
);
}

View File

@@ -1,3 +1,8 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { Button } from '@affine/admin/components/ui/button';
import {
DropdownMenu,
@@ -8,9 +13,13 @@ import {
DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu';
import { useQuery } from '@affine/core/hooks/use-query';
import { FeatureType, getCurrentUserFeaturesQuery } from '@affine/graphql';
import { CircleUser } from 'lucide-react';
import { useEffect } from 'react';
import {
FeatureType,
getCurrentUserFeaturesQuery,
serverConfigQuery,
} from '@affine/graphql';
import { CircleUser, MoreVertical } from 'lucide-react';
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@@ -20,9 +29,32 @@ export function UserDropdown() {
} = useQuery({
query: getCurrentUserFeaturesQuery,
});
const {
data: { serverConfig },
} = useQuery({
query: serverConfigQuery,
});
const navigate = useNavigate();
const handleLogout = useCallback(() => {
fetch('/api/auth/sign-out', {
method: 'POST',
})
.then(() => {
navigate('/admin/auth');
})
.catch(err => {
toast.error(`Failed to logout: ${err.message}`);
});
}, [navigate]);
useEffect(() => {
if (serverConfig.initialized === false) {
navigate('/admin/setup');
return;
}
if (!currentUser) {
navigate('/admin/auth');
return;
@@ -32,28 +64,47 @@ export function UserDropdown() {
navigate('/admin/auth');
return;
}
}, [currentUser, navigate]);
const avatar = currentUser?.avatarUrl ? (
<img src={currentUser?.avatarUrl} />
) : (
<CircleUser size={24} />
);
}, [currentUser, navigate, serverConfig.initialized]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
{avatar}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-between px-4 py-3 flex-nowrap">
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
<Avatar className="w-6 h-6">
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
<AvatarFallback>
<CircleUser size={24} />
</AvatarFallback>
</Avatar>
{currentUser?.name ? (
<span className="text-sm text-nowrap text-ellipsis break-words overflow-hidden">
{currentUser?.name}
</span>
) : (
// Fallback to email prefix if name is not available
<span className="text-sm">{currentUser?.email.split('@')[0]}</span>
)}
<span
className="rounded p-1 text-xs"
style={{
backgroundColor: 'rgba(30, 150, 235, 0.20)',
color: 'rgba(30, 150, 235, 1)',
}}
>
Admin
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-2 p-1 h-6">
<MoreVertical size={20} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleLogout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { useCallback } from 'react';
type CreateAdminProps = {
name: string;
email: string;
password: string;
invalidEmail: boolean;
invalidPassword: boolean;
passwordLimits: {
minLength: number;
maxLength: number;
};
onNameChange: (name: string) => void;
onEmailChange: (email: string) => void;
onPasswordChange: (password: string) => void;
};
export const CreateAdmin = ({
name,
email,
password,
invalidEmail,
invalidPassword,
passwordLimits,
onNameChange,
onEmailChange,
onPasswordChange,
}: CreateAdminProps) => {
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onNameChange(event.target.value);
},
[onNameChange]
);
const handleEmailChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onEmailChange(event.target.value);
},
[onEmailChange]
);
const handlePasswordChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onPasswordChange(event.target.value);
},
[onPasswordChange]
);
return (
<div className="flex flex-col h-full w-full mt-24 max-lg:items-center max-lg:mt-16 max-md:mt-5 lg:pl-0">
<div className="flex flex-col pl-1 max-lg:p-4 max-w-96 mb-5">
<div className="flex flex-col mb-16 max-sm:mb-6">
<h1 className="text-lg font-semibold">
Create Administrator Account
</h1>
<p className="text-sm text-muted-foreground">
This account can also be used to log in as an AFFiNE user.
</p>
</div>
<div className="flex flex-col gap-9">
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
value={name}
onChange={handleNameChange}
required
/>
</div>
<div className="grid gap-2 relative">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={handleEmailChange}
required
/>
<p
className={`absolute text-sm text-red-500 -bottom-6 ${invalidEmail ? '' : 'opacity-0 pointer-events-none'}`}
>
Invalid email address.
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
</div>
<Input
id="password"
type="password"
value={password}
onChange={handlePasswordChange}
min={passwordLimits.minLength}
max={passwordLimits.maxLength}
required
/>
<p
className={`text-sm text-muted-foreground ${invalidPassword && 'text-red-500'}`}
>
{invalidPassword ? 'Invalid password. ' : ''}Please enter{' '}
{String(passwordLimits.minLength)}-
{String(passwordLimits.maxLength)} digit password, it is
recommended to include 2+ of: uppercase, lowercase, numbers,
symbols.
</p>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,222 @@
import { Button } from '@affine/admin/components/ui/button';
import type { CarouselApi } from '@affine/admin/components/ui/carousel';
import {
Carousel,
CarouselContent,
CarouselItem,
} from '@affine/admin/components/ui/carousel';
import { validateEmailAndPassword } from '@affine/admin/utils';
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 { CreateAdmin } from './create-admin';
export enum CarouselSteps {
Welcome = 0,
CreateAdmin,
SettingsDone,
}
const Welcome = () => {
return (
<div
className="flex flex-col h-full w-full mt-60 max-lg:items-center max-lg:mt-16"
style={{ minHeight: '300px' }}
>
<h1 className="text-5xl font-extrabold max-lg:text-3xl max-lg:font-bold">
Welcome to AFFiNE
</h1>
<p className="mt-5 font-semibold text-xl max-lg:px-4 max-lg:text-lg">
Configure your Self Host AFFiNE with a few simple settings.
</p>
</div>
);
};
const SettingsDone = () => {
return (
<div
className="flex flex-col h-full w-full mt-60 max-lg:items-center max-lg:mt-16"
style={{ minHeight: '300px' }}
>
<h1 className="text-5xl font-extrabold max-lg:text-3xl max-lg:font-bold">
All Settings Done
</h1>
<p className="mt-5 font-semibold text-xl max-lg:px-4 max-lg:text-lg">
AFFiNE is ready to use.
</p>
</div>
);
};
const CarouselItemElements = {
[CarouselSteps.Welcome]: Welcome,
[CarouselSteps.CreateAdmin]: CreateAdmin,
[CarouselSteps.SettingsDone]: SettingsDone,
};
export const Form = () => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const navigate = useNavigate();
const [nameValue, setNameValue] = useState('');
const [emailValue, setEmailValue] = useState('');
const [passwordValue, setPasswordValue] = useState('');
const [invalidEmail, setInvalidEmail] = useState(false);
const [invalidPassword, setInvalidPassword] = useState(false);
const { data } = useQuery({
query: serverConfigQuery,
});
const passwordLimits = data.serverConfig.credentialsRequirement.password;
const isCreateAdminStep = current - 1 === CarouselSteps.CreateAdmin;
const disableContinue =
(!nameValue || !emailValue || !passwordValue) && isCreateAdminStep;
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
const createAdmin = useCallback(async () => {
if (invalidEmail || invalidPassword) return;
try {
const createResponse = await fetch('/api/setup/create-admin-user', {
method: 'POST',
body: JSON.stringify({
email: emailValue,
password: passwordValue,
}),
headers: {
'Content-Type': 'application/json',
},
});
if (!createResponse.ok) {
const errorData = await createResponse.json();
throw new Error(errorData.message || 'Failed to create admin');
}
await createResponse.json();
toast.success('Admin account created successfully.');
} catch (err) {
toast.error((err as Error).message);
console.error(err);
throw err;
}
}, [emailValue, invalidEmail, invalidPassword, passwordValue]);
const onNext = useCallback(async () => {
if (isCreateAdminStep) {
if (
!validateEmailAndPassword(
emailValue,
passwordValue,
passwordLimits,
setInvalidEmail,
setInvalidPassword
)
) {
return;
} else {
try {
await createAdmin();
} catch (e) {
console.error(e);
return;
}
}
}
if (current === count) {
return navigate('/', { replace: true });
}
api?.scrollNext();
}, [
api,
count,
createAdmin,
current,
emailValue,
isCreateAdminStep,
navigate,
passwordLimits,
passwordValue,
]);
const onPrevious = useCallback(() => {
if (current === count) {
return navigate('/admin', { replace: true });
}
api?.scrollPrev();
}, [api, count, current, navigate]);
return (
<div className="flex flex-col justify-between h-full w-full lg:pl-36 max-lg:items-center ">
<Carousel
setApi={setApi}
className=" h-full w-full"
opts={{ watchDrag: false }}
>
<CarouselContent>
{Object.entries(CarouselItemElements).map(([key, Element]) => (
<CarouselItem key={key}>
<Element
name={nameValue}
email={emailValue}
password={passwordValue}
invalidEmail={invalidEmail}
invalidPassword={invalidPassword}
passwordLimits={passwordLimits}
onNameChange={setNameValue}
onEmailChange={setEmailValue}
onPasswordChange={setPasswordValue}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div>
{current > 1 && (
<Button className="mr-3" onClick={onPrevious} variant="outline">
{current === count ? 'Goto Admin Panel' : 'Back'}
</Button>
)}
<Button onClick={onNext} disabled={disableContinue}>
{current === count ? 'Open AFFiNE' : 'Continue'}
</Button>
</div>
<div className="py-2 px-0 text-sm mt-16 max-lg:mt-5 relative">
{Array.from({ length: count }).map((_, index) => (
<span
key={index}
className={`inline-block w-16 h-1 rounded mr-1 ${
index <= current - 1
? 'bg-primary'
: 'bg-muted-foreground opacity-20'
}`}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { Form } from './form';
import logo from './logo.svg';
export function Setup() {
return (
<div className="w-full lg:grid lg:grid-cols-2 h-screen">
<div className="flex items-center justify-center py-12 h-full">
<Form />
</div>
<div className="hidden lg:block relative overflow-hidden ">
<img
src={logo}
alt="Image"
className="absolute object-right-bottom bottom-0 right-0 h-3/4"
/>
</div>
</div>
);
}
export { Setup as Component };

View File

@@ -0,0 +1,5 @@
<svg width="730" height="703" viewBox="0 0 730 703" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M778.049 644.705C764.467 620.365 741.846 579.955 719.499 540.04C712.719 527.929 705.985 515.888 699.526 504.342C686.24 480.591 674.142 458.937 665.514 443.48C605.64 336.671 487.764 125.197 428.872 20.6265C410.725 -8.5204 368.678 -6.21127 353.065 23.9724C335.352 55.664 315.858 90.5131 295.268 127.341C288.74 139.028 282.075 150.928 275.341 162.968C189.559 316.313 89.1678 495.813 17.6524 623.663C14.0002 630.497 7.19787 641.642 3.79672 649.017C-2.161 662.33 -0.99684 678.918 6.51308 691.217C14.9817 705.779 30.8233 713.767 47.0073 712.942C66.4783 712.942 107.771 712.918 161.163 712.942C173.831 712.942 187.185 712.942 201.086 712.942C386.734 712.942 670.33 713.06 739.928 712.942C773.734 713.013 794.94 674.747 778.094 644.681L778.049 644.705ZM378.425 508.89L344.254 447.792C338.251 437.048 345.76 423.617 357.767 423.617H426.11C438.139 423.617 445.649 437.048 439.623 447.792L405.452 508.89C399.448 519.635 384.429 519.635 378.402 508.89H378.425ZM321.176 395.106C318.346 387.661 315.903 380.097 313.895 372.416L426.954 395.106H321.176ZM372.81 555.85C367.993 562.118 362.858 568.079 357.425 573.734L319.921 461.317L372.787 555.85H372.81ZM481.875 429.319C489.522 430.497 497.1 432.123 504.542 434.15L428.986 523.876L481.875 429.319ZM306.933 334.126C305.723 322.769 305.198 311.294 305.243 299.796L454.552 374.984L306.91 334.15L306.933 334.126ZM289.219 446.685L328.755 599.017C319.83 605.779 310.471 612 300.792 617.702L289.196 446.685H289.219ZM540.151 447.085C550.286 451.68 560.17 456.958 569.803 462.755L432.068 558.654L540.151 447.085ZM308.667 251.304C311.407 230.428 315.63 209.857 320.743 190.136L547.57 393.669L308.69 251.304H308.667ZM258.403 638.932C239.526 646.92 220.169 653.423 201.063 658.701L258.403 354.202V638.932ZM608.767 490.04C624.906 502.929 640.062 516.996 654.055 531.416L369.888 632.405L608.767 490.04ZM401.868 75.6922C443.686 150.598 504.405 259.221 562.247 362.614L346.285 139.688C358.977 117.021 371.007 95.5083 382.1 75.6451C386.506 67.7988 397.463 67.7988 401.868 75.6451V75.6922ZM64.5609 643.362C76.4535 622.179 92.7972 593.126 96.4267 586.434C130.849 524.889 177.21 442.019 225.1 356.393L146.006 661.057C117.678 661.057 93.2538 661.057 74.4447 661.057C65.6565 661.057 60.1553 651.232 64.5609 643.385V643.362ZM709.501 661.104C643.555 661.104 515.521 661.104 388.4 661.104L683.57 579.46C699.046 607.122 711.441 629.271 719.385 643.456C723.79 651.302 718.289 661.104 709.523 661.104H709.501Z"
fill="black" fill-opacity="0.05" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,194 +0,0 @@
import { Badge } from '@affine/admin/components/ui/badge';
import { Button } from '@affine/admin/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@affine/admin/components/ui/card';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@affine/admin/components/ui/tabs';
import { useQuery } from '@affine/core/hooks/use-query';
import { listUsersQuery } from '@affine/graphql';
import {
CircleUser,
File,
ListFilter,
MoreHorizontal,
PlusCircle,
} from 'lucide-react';
import { Nav } from '../nav';
export function Users() {
const {
data: { users },
} = useQuery({
query: listUsersQuery,
variables: {
filter: {
first: 10,
skip: 0,
},
},
});
const usersCells = users.map(user => {
const avatar = user.avatarUrl ? (
<img
alt="User avatar"
className="aspect-square rounded-md object-cover"
height="64"
src={user.avatarUrl}
width="64"
/>
) : (
<CircleUser size={36} />
);
return (
<TableRow key={user.id}>
<TableCell className="hidden sm:table-cell">{avatar}</TableCell>
<TableCell className="hidden md:table-cell">{user.id}</TableCell>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>
<Badge variant="outline">
{user.emailVerified ? 'Email Verified' : 'Not yet verified'}
</Badge>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="hidden md:table-cell">
{user.hasPassword ? '✅' : '❌'}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-haspopup="true" size="icon" variant="ghost">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Toggle menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
});
return (
<Nav>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
<Tabs defaultValue="all">
<div className="flex items-center">
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="draft">Draft</TabsTrigger>
<TabsTrigger value="archived" className="hidden sm:flex">
Archived
</TabsTrigger>
</TabsList>
<div className="ml-auto flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1">
<ListFilter className="h-3.5 w-3.5" />
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
Filter
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>
Active
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>Draft</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>Archived</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" variant="outline" className="h-7 gap-1">
<File className="h-3.5 w-3.5" />
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
Export
</span>
</Button>
<Button size="sm" className="h-7 gap-1">
<PlusCircle className="h-3.5 w-3.5" />
<span className="sr-only sm:not-sr-only sm:whitespace-nowrap">
Add User
</span>
</Button>
</div>
</div>
<TabsContent value="all">
<Card x-chunk="dashboard-06-chunk-0">
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>
Manage your users and edit their properties.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="hidden w-[100px] sm:table-cell">
<span className="sr-only">Image</span>
</TableHead>
<TableHead className="hidden md:table-cell">Id</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">
Has password
</TableHead>
<TableHead>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>{usersCells}</TableBody>
</Table>
</CardContent>
<CardFooter>
<div className="text-xs text-muted-foreground">
Showing <strong>1-10</strong> of <strong>32</strong> products
</div>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</main>
</Nav>
);
}
export { Users as Component };

View File

@@ -4,3 +4,29 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const emailRegex =
/^(?:(?:[^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|((?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
interface PasswordLimits {
minLength: number;
maxLength: number;
}
export const validateEmailAndPassword = (
email: string,
password: string,
passwordLimits: PasswordLimits,
setInvalidEmail?: (invalid: boolean) => void,
setInvalidPassword?: (invalid: boolean) => void
) => {
const isValidEmail = emailRegex.test(email);
const isValidPassword =
password.length >= passwordLimits.minLength &&
password.length <= passwordLimits.maxLength;
setInvalidEmail?.(!isValidEmail);
setInvalidPassword?.(!isValidPassword);
return isValidEmail && isValidPassword;
};