diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index dab44d4f20..11672cf262 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -21,6 +21,7 @@ import { Throttle, URLHelper, } from '../../fundamentals'; +import { Admin } from '../common'; import { UserService } from '../user'; import { UserType } from '../user/types'; import { validators } from '../utils/validators'; @@ -291,4 +292,19 @@ export class AuthResolver { return emailVerifiedAt !== null; } + + @Admin() + @Mutation(() => String, { + description: 'Create change password url', + }) + async createChangePasswordUrl( + @Args('userId') userId: string, + @Args('callbackUrl') callbackUrl: string + ): Promise { + const token = await this.token.createToken( + TokenType.ChangePassword, + userId + ); + return this.url.link(callbackUrl, { token }); + } } diff --git a/packages/backend/server/src/core/features/management.ts b/packages/backend/server/src/core/features/management.ts index 9ff93bb5b7..aa838f69d1 100644 --- a/packages/backend/server/src/core/features/management.ts +++ b/packages/backend/server/src/core/features/management.ts @@ -42,6 +42,10 @@ export class FeatureManagementService { return this.feature.addUserFeature(userId, FeatureType.Admin, 'Admin user'); } + removeAdmin(userId: string) { + return this.feature.removeUserFeature(userId, FeatureType.Admin); + } + // ======== Early Access ======== async addEarlyAccess( userId: string, diff --git a/packages/backend/server/src/core/features/resolver.ts b/packages/backend/server/src/core/features/resolver.ts index 1d002a466b..835301588d 100644 --- a/packages/backend/server/src/core/features/resolver.ts +++ b/packages/backend/server/src/core/features/resolver.ts @@ -57,12 +57,15 @@ export class FeatureManagementResolver { @Admin() @Mutation(() => Int) - async removeEarlyAccess(@Args('email') email: string): Promise { + async removeEarlyAccess( + @Args('email') email: string, + @Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType + ): Promise { const user = await this.users.findUserByEmail(email); if (!user) { throw new UserNotFound(); } - return this.feature.removeEarlyAccess(user.id); + return this.feature.removeEarlyAccess(user.id, type); } @Admin() @@ -90,4 +93,18 @@ export class FeatureManagementResolver { return true; } + + @Admin() + @Mutation(() => Boolean) + async removeAdminister(@Args('email') email: string): Promise { + const user = await this.users.findUserByEmail(email); + + if (!user) { + throw new UserNotFound(); + } + + await this.feature.removeAdmin(user.id); + + return true; + } } diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index e16050e02d..90fba524e3 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -22,6 +22,7 @@ import { validators } from '../utils/validators'; import { UserService } from './service'; import { DeleteAccount, + ManageUserInput, RemoveAvatar, UpdateUserInput, UserOrLimitedUser, @@ -174,6 +175,13 @@ export class UserManagementResolver { private readonly user: UserService ) {} + @Query(() => Int, { + description: 'Get users count', + }) + async usersCount(): Promise { + return this.db.user.count(); + } + @Query(() => [UserType], { description: 'List registered users', }) @@ -208,6 +216,26 @@ export class UserManagementResolver { return sessionUser(user); } + @Query(() => UserType, { + name: 'userByEmail', + description: 'Get user by email for admin', + nullable: true, + }) + async getUserByEmail(@Args('email') email: string) { + const user = await this.db.user.findUnique({ + select: { ...this.user.defaultUserSelect, password: true }, + where: { + email, + }, + }); + + if (!user) { + return null; + } + + return sessionUser(user); + } + @Mutation(() => UserType, { description: 'Create a new user', }) @@ -231,4 +259,36 @@ export class UserManagementResolver { await this.user.deleteUser(id); return { success: true }; } + + @Mutation(() => UserType, { + description: 'Update a user', + }) + async updateUser( + @Args('id') id: string, + @Args('input') input: ManageUserInput + ): Promise { + const user = await this.db.user.findUnique({ + select: { ...this.user.defaultUserSelect, password: true }, + where: { id }, + }); + + if (!user) { + throw new UserNotFound(); + } + validators.assertValidEmail(input.email); + if (input.email !== user.email) { + const exists = await this.db.user.findFirst({ + where: { email: input.email }, + }); + if (exists) { + throw new Error('Email already exists'); + } + } + return sessionUser( + await this.user.updateUser(user.id, { + name: input.name, + email: input.email, + }) + ); + } } diff --git a/packages/backend/server/src/core/user/types.ts b/packages/backend/server/src/core/user/types.ts index e57eb656ab..463715ca67 100644 --- a/packages/backend/server/src/core/user/types.ts +++ b/packages/backend/server/src/core/user/types.ts @@ -83,6 +83,15 @@ export class UpdateUserInput implements Partial { name?: string; } +@InputType() +export class ManageUserInput { + @Field({ description: 'User name', nullable: true }) + name?: string; + + @Field({ description: 'User email' }) + email!: string; +} + declare module '../../fundamentals/event/def' { interface UserEvents { admin: { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 1a6ecdb0be..7288f8e09f 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -396,6 +396,14 @@ input ListUserInput { skip: Int = 0 } +input ManageUserInput { + """User email""" + email: String! + + """User name""" + name: String +} + type MissingOauthQueryParameterDataType { name: String! } @@ -412,6 +420,9 @@ type Mutation { """Cleanup sessions""" cleanupCopilotSession(options: DeleteSessionInput!): [String!]! + """Create change password url""" + createChangePasswordUrl(callbackUrl: String!, userId: String!): String! + """Create a subscription checkout link of stripe""" createCheckoutSession(input: CreateCheckoutSessionInput!): String! @@ -445,10 +456,11 @@ type Mutation { leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean! publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage! recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime! + removeAdminister(email: String!): Boolean! """Remove user avatar""" removeAvatar: RemoveAvatar! - removeEarlyAccess(email: String!): Int! + removeEarlyAccess(email: String!, type: EarlyAccessType!): Int! removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription! revoke(userId: String!, workspaceId: String!): Boolean! @@ -474,6 +486,9 @@ type Mutation { updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]! updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription! + """Update a user""" + updateUser(id: String!, input: ManageUserInput!): UserType! + """Update workspace""" updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType! @@ -544,12 +559,18 @@ type Query { """Get user by email""" user(email: String!): UserOrLimitedUser + """Get user by email for admin""" + userByEmail(email: String!): UserType + """Get user by id""" userById(id: String!): UserType! """List registered users""" users(filter: ListUserInput!): [UserType!]! + """Get users count""" + usersCount: Int! + """Get workspace by id""" workspace(id: String!): WorkspaceType! diff --git a/packages/frontend/admin/package.json b/packages/frontend/admin/package.json index e026969441..9bb73f3f5d 100644 --- a/packages/frontend/admin/package.json +++ b/packages/frontend/admin/package.json @@ -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", diff --git a/packages/frontend/admin/src/app.tsx b/packages/frontend/admin/src/app.tsx index 75c34c19ab..921338fbe7 100644 --- a/packages/frontend/admin/src/app.tsx +++ b/packages/frontend/admin/src/app.tsx @@ -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: , + }, + { + 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'), }, ], }, diff --git a/packages/frontend/admin/src/components/ui/sheet.tsx b/packages/frontend/admin/src/components/ui/sheet.tsx index f7999d711a..39a879fb9c 100644 --- a/packages/frontend/admin/src/components/ui/sheet.tsx +++ b/packages/frontend/admin/src/components/ui/sheet.tsx @@ -52,23 +52,30 @@ interface SheetContentProps const SheetContent = React.forwardRef< React.ElementRef, - SheetContentProps ->(({ side = 'right', className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); + SheetContentProps & { withoutCloseButton?: boolean } +>( + ( + { side = 'right', className, children, withoutCloseButton, ...props }, + ref + ) => ( + + + + {children} + {!withoutCloseButton && ( + + + Close + + )} + + + ) +); SheetContent.displayName = SheetPrimitive.Content.displayName; const SheetHeader = ({ diff --git a/packages/frontend/admin/src/modules/accounts/components/ceate-user-panel.tsx b/packages/frontend/admin/src/modules/accounts/components/ceate-user-panel.tsx new file mode 100644 index 0000000000..64f9953f8d --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/ceate-user-panel.tsx @@ -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([]); + + 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 ( +
+
+ + Create Account + +
+ +
+
+
+ + setName(e.target.value)} + /> +
+ +
+ + setEmail(e.target.value)} + /> +
{' '} + +
+ + setPassword(e.target.value)} + /> +
+
+ +
+ + + +
+
+
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/columns.tsx b/packages/frontend/admin/src/modules/accounts/components/columns.tsx new file mode 100644 index 0000000000..a5c8b86322 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/columns.tsx @@ -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; +}) => ( +
+ {condition ? ( + <> + {IconTrue} + {textTrue} + + ) : ( + <> + {IconFalse} + {textFalse} + + )} +
+); + +export const columns: ColumnDef[] = [ + { + accessorKey: 'info', + cell: ({ row }) => ( +
+ + + + + + +
+
+ {row.original.name}{' '} + {row.original.features.includes(FeatureType.Admin) && ( + + Admin + + )} +
+
+ {row.original.email} +
+
+
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'property', + cell: ({ row }) => ( +
+
+
{row.original.id}
+
+ } + IconFalse={} + textTrue="Password Set" + textFalse="No Password" + /> + + } + IconFalse={} + textTrue="Email Verified" + textFalse="Email Not Verified" + /> +
+
+ +
+ ), + }, +]; diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-pagination.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-pagination.tsx new file mode 100644 index 0000000000..f5f9f59db3 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-pagination.tsx @@ -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 { + table: Table; +} + +export function DataTablePagination({ + table, +}: DataTablePaginationProps) { + 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 ( +
+
+

Rows per page

+ +
+
+
+ Page {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx new file mode 100644 index 0000000000..1c5c99421a --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx @@ -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 { + row: Row; +} + +export function DataTableRowActions({ + row, +}: 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 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( + + ); + 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 ( +
+ + + + + +
+ {user.name} +
+ + + Reset Password + + + Edit + + + + + Delete + +
+
+ + + +
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx new file mode 100644 index 0000000000..47929cbb28 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx @@ -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 { + data: TData[]; + setDataTable: (data: TData[]) => void; +} + +function useDebouncedValue(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({ + data, + setDataTable, +}: DataTableToolbarProps) { + const [value, setValue] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); + const debouncedValue = useDebouncedValue(value, 500); + const { setRightPanelContent, openPanel, isOpen } = useRightPanel(); + + const handleConfirm = useCallback(() => { + setRightPanelContent(); + 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 } }) => { + startTransition(() => { + setValue(e.currentTarget.value); + }); + }, + [] + ); + + const handleCancel = useCallback(() => { + setDialogOpen(false); + }, []); + + const handleOpenConfirm = useCallback(() => { + if (isOpen) { + return setDialogOpen(true); + } + return handleConfirm(); + }, [handleConfirm, isOpen]); + + return ( +
+
+ +
+ + +
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table.tsx new file mode 100644 index 0000000000..f1f66214f2 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/data-table.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; + pagination: PaginationState; + onPaginationChange: Dispatch< + SetStateAction<{ + pageIndex: number; + pageSize: number; + }> + >; +} + +export function DataTable({ + columns, + data, + pagination, + onPaginationChange, +}: DataTableProps) { + 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 ( +
+ + + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ + +
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/delete-account.tsx b/packages/frontend/admin/src/modules/accounts/components/delete-account.tsx new file mode 100644 index 0000000000..0613404ffe --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/delete-account.tsx @@ -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) => { + setInput(event.target.value); + }, + [setInput] + ); + + useEffect(() => { + if (!open) { + setInput(''); + } + }, [open]); + + return ( + + + + Delete Account ? + + {email} will be permanently + deleted. This operation is irreversible. Please proceed with + caution. + + + + +
+ + +
+
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/discard-changes.tsx b/packages/frontend/admin/src/modules/accounts/components/discard-changes.tsx new file mode 100644 index 0000000000..2038a09d09 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/discard-changes.tsx @@ -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 ( + + + + Discard Changes + + Changes to this user will not be saved. + + + +
+ + +
+
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/edit-panel.tsx b/packages/frontend/admin/src/modules/accounts/components/edit-panel.tsx new file mode 100644 index 0000000000..fe95ad40ae --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/edit-panel.tsx @@ -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 ( +
+
+ + Edit Account + +
+ +
+
+
+ + setName(e.target.value)} + /> +
+ +
+ + setEmail(e.target.value)} + /> +
+
+ +
+ + + +
+ +
+
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/logo.tsx b/packages/frontend/admin/src/modules/accounts/components/logo.tsx new file mode 100644 index 0000000000..330d103de8 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/logo.tsx @@ -0,0 +1,16 @@ +export const Logo = () => { + return ( + + + + ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/reset-password.tsx b/packages/frontend/admin/src/modules/accounts/components/reset-password.tsx new file mode 100644 index 0000000000..68b0708964 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/reset-password.tsx @@ -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 ( + + + + Account Recovery Link + + Please send this recovery link to the user and instruct them to + complete it. + + + +
+ + +
+
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts new file mode 100644 index 0000000000..19a586bb1b --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts @@ -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]); +}; diff --git a/packages/frontend/admin/src/modules/accounts/index.tsx b/packages/frontend/admin/src/modules/accounts/index.tsx new file mode 100644 index 0000000000..62083b6465 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/index.tsx @@ -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 } />; +} + +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 ( +
+
+
Accounts
+
+ + + +
+ ); +} +export { Accounts as Component }; diff --git a/packages/frontend/admin/src/modules/accounts/schema.ts b/packages/frontend/admin/src/modules/accounts/schema.ts new file mode 100644 index 0000000000..6c5df12c84 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/schema.ts @@ -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; diff --git a/packages/frontend/admin/src/modules/home/index.tsx b/packages/frontend/admin/src/modules/home/index.tsx deleted file mode 100644 index 8659f8109b..0000000000 --- a/packages/frontend/admin/src/modules/home/index.tsx +++ /dev/null @@ -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 ( - - ); -} - -function MainDashBoard() { - return ( - <> -
- - - Total Revenue - - - -
$45,231.89
-

- +20.1% from last month -

-
-
- - - Subscriptions - - - -
+2350
-

- +180.1% from last month -

-
-
- - - Sales - - - -
+12,234
-

- +19% from last month -

-
-
- - - Active Now - - - -
+573
-

- +201 since last hour -

-
-
-
-
- - -
- Transactions - - Recent transactions from your store. - -
- -
- - - - - Customer - Type - - Status - - Date - Amount - - - - - -
Liam Johnson
-
- liam@example.com -
-
- Sale - - - Approved - - - - 2023-06-23 - - $250.00 -
- - -
Olivia Smith
-
- olivia@example.com -
-
- - Refund - - - - Declined - - - - 2023-06-24 - - $150.00 -
- - -
Noah Williams
-
- noah@example.com -
-
- - Subscription - - - - Approved - - - - 2023-06-25 - - $350.00 -
- - -
Emma Brown
-
- emma@example.com -
-
- Sale - - - Approved - - - - 2023-06-26 - - $450.00 -
- - -
Liam Johnson
-
- liam@example.com -
-
- Sale - - - Approved - - - - 2023-06-27 - - $550.00 -
-
-
-
-
- - - Recent Sales - - -
- - - OM - -
-

- Olivia Martin -

-

- olivia.martin@email.com -

-
-
+$1,999.00
-
-
- - - JL - -
-

Jackson Lee

-

- jackson.lee@email.com -

-
-
+$39.00
-
-
- - - IN - -
-

- Isabella Nguyen -

-

- isabella.nguyen@email.com -

-
-
+$299.00
-
-
- - - WK - -
-

William Kim

-

will@email.com

-
-
+$99.00
-
-
- - - SD - -
-

Sofia Davis

-

- sofia.davis@email.com -

-
-
+$39.00
-
-
-
-
- - ); -} - -export { Dashboard as Component }; diff --git a/packages/frontend/admin/src/modules/layout.tsx b/packages/frontend/admin/src/modules/layout.tsx new file mode 100644 index 0000000000..bda1554f40 --- /dev/null +++ b/packages/frontend/admin/src/modules/layout.tsx @@ -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( + 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(null); + const [open, setOpen] = useState(false); + const rightPanelRef = useRef(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 ( + + +
+ + + + {content} + + + +
+
+
+ ); +} + +export const LeftPanel = ({ activeTab }: { activeTab: string }) => { + const isSmallScreen = useMediaQuery('(max-width: 768px)'); + + if (isSmallScreen) { + return ( + + + + + + AFFiNE + + Admin panel for managing accounts, AI, config, and settings + + + +
+
+ + AFFiNE +
+ +
+
+
+ ); + } + + return ( +
+
+ + AFFiNE +
+ +
+ ); +}; +export const RightPanel = ({ + rightPanelRef, + onExpand, + onCollapse, +}: { + rightPanelRef: RefObject; + 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 ( + + + Right Panel + + For displaying additional information + + + + {rightPanelContent} + + + ); + } + + return ( + <> + + + {rightPanelContent} + + + ); +}; diff --git a/packages/frontend/admin/src/modules/nav/nav.tsx b/packages/frontend/admin/src/modules/nav/nav.tsx new file mode 100644 index 0000000000..f22d2a4a7f --- /dev/null +++ b/packages/frontend/admin/src/modules/nav/nav.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/packages/frontend/admin/src/modules/nav/user-dropdown.tsx b/packages/frontend/admin/src/modules/nav/user-dropdown.tsx index aa20ff8e59..00673d5136 100644 --- a/packages/frontend/admin/src/modules/nav/user-dropdown.tsx +++ b/packages/frontend/admin/src/modules/nav/user-dropdown.tsx @@ -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 ? ( - - ) : ( - - ); + }, [currentUser, navigate, serverConfig.initialized]); return ( - - - - - - {currentUser?.name} - - Settings - Support - - Logout - - +
+
+ + + + + + + {currentUser?.name ? ( + + {currentUser?.name} + + ) : ( + // Fallback to email prefix if name is not available + {currentUser?.email.split('@')[0]} + )} + + Admin + +
+ + + + + + {currentUser?.name} + + Logout + + +
); } diff --git a/packages/frontend/admin/src/modules/setup/create-admin.tsx b/packages/frontend/admin/src/modules/setup/create-admin.tsx new file mode 100644 index 0000000000..46f5d31162 --- /dev/null +++ b/packages/frontend/admin/src/modules/setup/create-admin.tsx @@ -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) => { + onNameChange(event.target.value); + }, + [onNameChange] + ); + const handleEmailChange = useCallback( + (event: React.ChangeEvent) => { + onEmailChange(event.target.value); + }, + [onEmailChange] + ); + + const handlePasswordChange = useCallback( + (event: React.ChangeEvent) => { + onPasswordChange(event.target.value); + }, + [onPasswordChange] + ); + + return ( +
+
+
+

+ Create Administrator Account +

+

+ This account can also be used to log in as an AFFiNE user. +

+
+
+
+ + +
+
+ + +

+ Invalid email address. +

+
+
+
+ +
+ +

+ {invalidPassword ? 'Invalid password. ' : ''}Please enter{' '} + {String(passwordLimits.minLength)}- + {String(passwordLimits.maxLength)} digit password, it is + recommended to include 2+ of: uppercase, lowercase, numbers, + symbols. +

+
+
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/setup/form.tsx b/packages/frontend/admin/src/modules/setup/form.tsx new file mode 100644 index 0000000000..c9249e5e5e --- /dev/null +++ b/packages/frontend/admin/src/modules/setup/form.tsx @@ -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 ( +
+

+ Welcome to AFFiNE +

+

+ Configure your Self Host AFFiNE with a few simple settings. +

+
+ ); +}; + +const SettingsDone = () => { + return ( +
+

+ All Settings Done +

+

+ AFFiNE is ready to use. +

+
+ ); +}; + +const CarouselItemElements = { + [CarouselSteps.Welcome]: Welcome, + [CarouselSteps.CreateAdmin]: CreateAdmin, + [CarouselSteps.SettingsDone]: SettingsDone, +}; + +export const Form = () => { + const [api, setApi] = useState(); + 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 ( +
+ + + {Object.entries(CarouselItemElements).map(([key, Element]) => ( + + + + ))} + + +
+ {current > 1 && ( + + )} + +
+ +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/setup/index.tsx b/packages/frontend/admin/src/modules/setup/index.tsx new file mode 100644 index 0000000000..7bd1dbd1e2 --- /dev/null +++ b/packages/frontend/admin/src/modules/setup/index.tsx @@ -0,0 +1,21 @@ +import { Form } from './form'; +import logo from './logo.svg'; + +export function Setup() { + return ( +
+
+
+
+
+ Image +
+
+ ); +} + +export { Setup as Component }; diff --git a/packages/frontend/admin/src/modules/setup/logo.svg b/packages/frontend/admin/src/modules/setup/logo.svg new file mode 100644 index 0000000000..83ef581bff --- /dev/null +++ b/packages/frontend/admin/src/modules/setup/logo.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/packages/frontend/admin/src/modules/users/index.tsx b/packages/frontend/admin/src/modules/users/index.tsx deleted file mode 100644 index f05508cfb0..0000000000 --- a/packages/frontend/admin/src/modules/users/index.tsx +++ /dev/null @@ -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 ? ( - User avatar - ) : ( - - ); - return ( - - {avatar} - {user.id} - {user.name} - - - {user.emailVerified ? 'Email Verified' : 'Not yet verified'} - - - {user.email} - - {user.hasPassword ? '✅' : '❌'} - - - - - - - - Actions - Edit - Delete - - - - - ); - }); - - return ( - - ); -} - -export { Users as Component }; diff --git a/packages/frontend/admin/src/utils.ts b/packages/frontend/admin/src/utils.ts index 9ad0df4269..a9d45474d7 100644 --- a/packages/frontend/admin/src/utils.ts +++ b/packages/frontend/admin/src/utils.ts @@ -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; +}; diff --git a/packages/frontend/graphql/src/graphql/add-admin.gql b/packages/frontend/graphql/src/graphql/add-admin.gql new file mode 100644 index 0000000000..5b8fc69711 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/add-admin.gql @@ -0,0 +1,3 @@ +mutation addToAdmin($email: String!) { + addAdminister(email: $email) +} diff --git a/packages/frontend/graphql/src/graphql/change-password-url.gql b/packages/frontend/graphql/src/graphql/change-password-url.gql new file mode 100644 index 0000000000..4685ccbfae --- /dev/null +++ b/packages/frontend/graphql/src/graphql/change-password-url.gql @@ -0,0 +1,3 @@ +mutation createChangePasswordUrl($callbackUrl: String!, $userId: String!) { + createChangePasswordUrl(callbackUrl: $callbackUrl, userId: $userId) +} diff --git a/packages/frontend/graphql/src/graphql/create-user.gql b/packages/frontend/graphql/src/graphql/create-user.gql new file mode 100644 index 0000000000..6df7653670 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/create-user.gql @@ -0,0 +1,5 @@ +mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { + id + } +} diff --git a/packages/frontend/graphql/src/graphql/delete-user.gql b/packages/frontend/graphql/src/graphql/delete-user.gql new file mode 100644 index 0000000000..a6281018bb --- /dev/null +++ b/packages/frontend/graphql/src/graphql/delete-user.gql @@ -0,0 +1,5 @@ +mutation deleteUser($id: String!) { + deleteUser(id: $id) { + success + } +} diff --git a/packages/frontend/graphql/src/graphql/early-access-add.gql b/packages/frontend/graphql/src/graphql/early-access-add.gql new file mode 100644 index 0000000000..47680a48c3 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/early-access-add.gql @@ -0,0 +1,3 @@ +mutation addToEarlyAccess($email: String!, $type: EarlyAccessType!) { + addToEarlyAccess(email: $email, type: $type) +} diff --git a/packages/frontend/graphql/src/graphql/early-access-remove.gql b/packages/frontend/graphql/src/graphql/early-access-remove.gql index dedea87709..eb99a5dcb0 100644 --- a/packages/frontend/graphql/src/graphql/early-access-remove.gql +++ b/packages/frontend/graphql/src/graphql/early-access-remove.gql @@ -1,3 +1,3 @@ -mutation removeEarlyAccess($email: String!) { - removeEarlyAccess(email: $email) +mutation removeEarlyAccess($email: String!, $type: EarlyAccessType!) { + removeEarlyAccess(email: $email, type: $type) } diff --git a/packages/frontend/graphql/src/graphql/get-user-by-email.gql b/packages/frontend/graphql/src/graphql/get-user-by-email.gql new file mode 100644 index 0000000000..ed23f20ab8 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-user-by-email.gql @@ -0,0 +1,20 @@ +query getUserByEmail($email: String!) { + userByEmail(email: $email) { + id + name + email + features + hasPassword + emailVerified + avatarUrl + quota { + humanReadable { + blobLimit + historyPeriod + memberLimit + name + storageQuota + } + } + } +} diff --git a/packages/frontend/graphql/src/graphql/get-users-count.gql b/packages/frontend/graphql/src/graphql/get-users-count.gql new file mode 100644 index 0000000000..e5b97fb79b --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-users-count.gql @@ -0,0 +1,3 @@ +query getUsersCount { + usersCount +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 1dc6ed5385..a8537c5caa 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -18,6 +18,17 @@ fragment CredentialsRequirement on CredentialsRequirementType { ...PasswordLimits } }` +export const addToAdminMutation = { + id: 'addToAdminMutation' as const, + operationName: 'addToAdmin', + definitionName: 'addAdminister', + containsFile: false, + query: ` +mutation addToAdmin($email: String!) { + addAdminister(email: $email) +}`, +}; + export const deleteBlobMutation = { id: 'deleteBlobMutation' as const, operationName: 'deleteBlob', @@ -81,6 +92,17 @@ mutation changeEmail($token: String!, $email: String!) { }`, }; +export const createChangePasswordUrlMutation = { + id: 'createChangePasswordUrlMutation' as const, + operationName: 'createChangePasswordUrl', + definitionName: 'createChangePasswordUrl', + containsFile: false, + query: ` +mutation createChangePasswordUrl($callbackUrl: String!, $userId: String!) { + createChangePasswordUrl(callbackUrl: $callbackUrl, userId: $userId) +}`, +}; + export const changePasswordMutation = { id: 'changePasswordMutation' as const, operationName: 'changePassword', @@ -167,6 +189,19 @@ mutation createCustomerPortal { }`, }; +export const createUserMutation = { + id: 'createUserMutation' as const, + operationName: 'createUser', + definitionName: 'createUser', + containsFile: false, + query: ` +mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { + id + } +}`, +}; + export const createWorkspaceMutation = { id: 'createWorkspaceMutation' as const, operationName: 'createWorkspace', @@ -195,6 +230,19 @@ mutation deleteAccount { }`, }; +export const deleteUserMutation = { + id: 'deleteUserMutation' as const, + operationName: 'deleteUser', + definitionName: 'deleteUser', + containsFile: false, + query: ` +mutation deleteUser($id: String!) { + deleteUser(id: $id) { + success + } +}`, +}; + export const deleteWorkspaceMutation = { id: 'deleteWorkspaceMutation' as const, operationName: 'deleteWorkspace', @@ -206,6 +254,17 @@ mutation deleteWorkspace($id: String!) { }`, }; +export const addToEarlyAccessMutation = { + id: 'addToEarlyAccessMutation' as const, + operationName: 'addToEarlyAccess', + definitionName: 'addToEarlyAccess', + containsFile: false, + query: ` +mutation addToEarlyAccess($email: String!, $type: EarlyAccessType!) { + addToEarlyAccess(email: $email, type: $type) +}`, +}; + export const earlyAccessUsersQuery = { id: 'earlyAccessUsersQuery' as const, operationName: 'earlyAccessUsers', @@ -236,8 +295,8 @@ export const removeEarlyAccessMutation = { definitionName: 'removeEarlyAccess', containsFile: false, query: ` -mutation removeEarlyAccess($email: String!) { - removeEarlyAccess(email: $email) +mutation removeEarlyAccess($email: String!, $type: EarlyAccessType!) { + removeEarlyAccess(email: $email, type: $type) }`, }; @@ -456,6 +515,34 @@ query getServerRuntimeConfig { }`, }; +export const getUserByEmailQuery = { + id: 'getUserByEmailQuery' as const, + operationName: 'getUserByEmail', + definitionName: 'userByEmail', + containsFile: false, + query: ` +query getUserByEmail($email: String!) { + userByEmail(email: $email) { + id + name + email + features + hasPassword + emailVerified + avatarUrl + quota { + humanReadable { + blobLimit + historyPeriod + memberLimit + name + storageQuota + } + } + } +}`, +}; + export const getUserFeaturesQuery = { id: 'getUserFeaturesQuery' as const, operationName: 'getUserFeatures', @@ -494,6 +581,17 @@ query getUser($email: String!) { }`, }; +export const getUsersCountQuery = { + id: 'getUsersCountQuery' as const, + operationName: 'getUsersCount', + definitionName: 'usersCount', + containsFile: false, + query: ` +query getUsersCount { + usersCount +}`, +}; + export const getWorkspaceFeaturesQuery = { id: 'getWorkspaceFeaturesQuery' as const, operationName: 'getWorkspaceFeatures', @@ -750,6 +848,17 @@ mutation recoverDoc($workspaceId: String!, $docId: String!, $timestamp: DateTime }`, }; +export const removeAdminMutation = { + id: 'removeAdminMutation' as const, + operationName: 'removeAdmin', + definitionName: 'removeAdminister', + containsFile: false, + query: ` +mutation removeAdmin($email: String!) { + removeAdminister(email: $email) +}`, +}; + export const removeAvatarMutation = { id: 'removeAvatarMutation' as const, operationName: 'removeAvatar', @@ -874,6 +983,7 @@ query serverConfig { name features type + initialized credentialsRequirement { ...CredentialsRequirement } @@ -918,6 +1028,21 @@ query subscription { }`, }; +export const updateAccountMutation = { + id: 'updateAccountMutation' as const, + operationName: 'updateAccount', + definitionName: 'updateUser', + containsFile: false, + query: ` +mutation updateAccount($id: String!, $input: ManageUserInput!) { + updateUser(id: $id, input: $input) { + id + name + email + } +}`, +}; + export const updateServerRuntimeConfigsMutation = { id: 'updateServerRuntimeConfigsMutation' as const, operationName: 'updateServerRuntimeConfigs', diff --git a/packages/frontend/graphql/src/graphql/remove-admin.gql b/packages/frontend/graphql/src/graphql/remove-admin.gql new file mode 100644 index 0000000000..315c9a8ae6 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/remove-admin.gql @@ -0,0 +1,3 @@ +mutation removeAdmin($email: String!) { + removeAdminister(email: $email) +} diff --git a/packages/frontend/graphql/src/graphql/server-config.gql b/packages/frontend/graphql/src/graphql/server-config.gql index ef17ef188a..1987157dbc 100644 --- a/packages/frontend/graphql/src/graphql/server-config.gql +++ b/packages/frontend/graphql/src/graphql/server-config.gql @@ -8,6 +8,7 @@ query serverConfig { name features type + initialized credentialsRequirement { ...CredentialsRequirement } diff --git a/packages/frontend/graphql/src/graphql/update-account.gql b/packages/frontend/graphql/src/graphql/update-account.gql new file mode 100644 index 0000000000..6d4f8fa2dc --- /dev/null +++ b/packages/frontend/graphql/src/graphql/update-account.gql @@ -0,0 +1,7 @@ +mutation updateAccount($id: String!, $input: ManageUserInput!) { + updateUser(id: $id, input: $input) { + id + name + email + } +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 1fdd6c0dd4..005b5b925c 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -453,6 +453,13 @@ export interface ListUserInput { skip: InputMaybe; } +export interface ManageUserInput { + /** User email */ + email: Scalars['String']['input']; + /** User name */ + name: InputMaybe; +} + export interface MissingOauthQueryParameterDataType { __typename?: 'MissingOauthQueryParameterDataType'; name: Scalars['String']['output']; @@ -469,6 +476,8 @@ export interface Mutation { changePassword: UserType; /** Cleanup sessions */ cleanupCopilotSession: Array; + /** Create change password url */ + createChangePasswordUrl: Scalars['String']['output']; /** Create a subscription checkout link of stripe */ createCheckoutSession: Scalars['String']['output']; /** Create a chat message */ @@ -494,6 +503,7 @@ export interface Mutation { leaveWorkspace: Scalars['Boolean']['output']; publishPage: WorkspacePage; recoverDoc: Scalars['DateTime']['output']; + removeAdminister: Scalars['Boolean']['output']; /** Remove user avatar */ removeAvatar: RemoveAvatar; removeEarlyAccess: Scalars['Int']['output']; @@ -520,6 +530,8 @@ export interface Mutation { /** update multiple server runtime configurable settings */ updateRuntimeConfigs: Array; updateSubscriptionRecurring: UserSubscription; + /** Update a user */ + updateUser: UserType; /** Update workspace */ updateWorkspace: WorkspaceType; /** Upload user avatar */ @@ -566,6 +578,11 @@ export interface MutationCleanupCopilotSessionArgs { options: DeleteSessionInput; } +export interface MutationCreateChangePasswordUrlArgs { + callbackUrl: Scalars['String']['input']; + userId: Scalars['String']['input']; +} + export interface MutationCreateCheckoutSessionArgs { input: CreateCheckoutSessionInput; } @@ -632,8 +649,13 @@ export interface MutationRecoverDocArgs { workspaceId: Scalars['String']['input']; } +export interface MutationRemoveAdministerArgs { + email: Scalars['String']['input']; +} + export interface MutationRemoveEarlyAccessArgs { email: Scalars['String']['input']; + type: EarlyAccessType; } export interface MutationRemoveWorkspaceFeatureArgs { @@ -726,6 +748,11 @@ export interface MutationUpdateSubscriptionRecurringArgs { recurring: SubscriptionRecurring; } +export interface MutationUpdateUserArgs { + id: Scalars['String']['input']; + input: ManageUserInput; +} + export interface MutationUpdateWorkspaceArgs { input: UpdateWorkspaceInput; } @@ -799,10 +826,14 @@ export interface Query { serverServiceConfigs: Array; /** Get user by email */ user: Maybe; + /** Get user by email for admin */ + userByEmail: Maybe; /** Get user by id */ userById: UserType; /** List registered users */ users: Array; + /** Get users count */ + usersCount: Scalars['Int']['output']; /** Get workspace by id */ workspace: WorkspaceType; /** Get all accessible workspaces for current user */ @@ -838,6 +869,10 @@ export interface QueryUserArgs { email: Scalars['String']['input']; } +export interface QueryUserByEmailArgs { + email: Scalars['String']['input']; +} + export interface QueryUserByIdArgs { id: Scalars['String']['input']; } @@ -1219,6 +1254,15 @@ export interface TokenType { token: Scalars['String']['output']; } +export type AddToAdminMutationVariables = Exact<{ + email: Scalars['String']['input']; +}>; + +export type AddToAdminMutation = { + __typename?: 'Mutation'; + addAdminister: boolean; +}; + export type DeleteBlobMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; hash: Scalars['String']['input']; @@ -1268,6 +1312,16 @@ export type ChangeEmailMutation = { changeEmail: { __typename?: 'UserType'; id: string; email: string }; }; +export type CreateChangePasswordUrlMutationVariables = Exact<{ + callbackUrl: Scalars['String']['input']; + userId: Scalars['String']['input']; +}>; + +export type CreateChangePasswordUrlMutation = { + __typename?: 'Mutation'; + createChangePasswordUrl: string; +}; + export type ChangePasswordMutationVariables = Exact<{ token: Scalars['String']['input']; newPassword: Scalars['String']['input']; @@ -1340,6 +1394,15 @@ export type CreateCustomerPortalMutation = { createCustomerPortal: string; }; +export type CreateUserMutationVariables = Exact<{ + input: CreateUserInput; +}>; + +export type CreateUserMutation = { + __typename?: 'Mutation'; + createUser: { __typename?: 'UserType'; id: string }; +}; + export type CreateWorkspaceMutationVariables = Exact<{ [key: string]: never }>; export type CreateWorkspaceMutation = { @@ -1359,6 +1422,15 @@ export type DeleteAccountMutation = { deleteAccount: { __typename?: 'DeleteAccount'; success: boolean }; }; +export type DeleteUserMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type DeleteUserMutation = { + __typename?: 'Mutation'; + deleteUser: { __typename?: 'DeleteAccount'; success: boolean }; +}; + export type DeleteWorkspaceMutationVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -1368,6 +1440,16 @@ export type DeleteWorkspaceMutation = { deleteWorkspace: boolean; }; +export type AddToEarlyAccessMutationVariables = Exact<{ + email: Scalars['String']['input']; + type: EarlyAccessType; +}>; + +export type AddToEarlyAccessMutation = { + __typename?: 'Mutation'; + addToEarlyAccess: number; +}; + export type EarlyAccessUsersQueryVariables = Exact<{ [key: string]: never }>; export type EarlyAccessUsersQuery = { @@ -1392,6 +1474,7 @@ export type EarlyAccessUsersQuery = { export type RemoveEarlyAccessMutationVariables = Exact<{ email: Scalars['String']['input']; + type: EarlyAccessType; }>; export type RemoveEarlyAccessMutation = { @@ -1619,6 +1702,35 @@ export type GetServerRuntimeConfigQuery = { }>; }; +export type GetUserByEmailQueryVariables = Exact<{ + email: Scalars['String']['input']; +}>; + +export type GetUserByEmailQuery = { + __typename?: 'Query'; + userByEmail: { + __typename?: 'UserType'; + id: string; + name: string; + email: string; + features: Array; + hasPassword: boolean | null; + emailVerified: boolean; + avatarUrl: string | null; + quota: { + __typename?: 'UserQuota'; + humanReadable: { + __typename?: 'UserQuotaHumanReadable'; + blobLimit: string; + historyPeriod: string; + memberLimit: string; + name: string; + storageQuota: string; + }; + } | null; + } | null; +}; + export type GetUserFeaturesQueryVariables = Exact<{ [key: string]: never }>; export type GetUserFeaturesQuery = { @@ -1653,6 +1765,10 @@ export type GetUserQuery = { | null; }; +export type GetUsersCountQueryVariables = Exact<{ [key: string]: never }>; + +export type GetUsersCountQuery = { __typename?: 'Query'; usersCount: number }; + export type GetWorkspaceFeaturesQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; @@ -1883,6 +1999,15 @@ export type RecoverDocMutation = { recoverDoc: string; }; +export type RemoveAdminMutationVariables = Exact<{ + email: Scalars['String']['input']; +}>; + +export type RemoveAdminMutation = { + __typename?: 'Mutation'; + removeAdminister: boolean; +}; + export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>; export type RemoveAvatarMutation = { @@ -1990,6 +2115,7 @@ export type ServerConfigQuery = { name: string; features: Array; type: ServerDeploymentType; + initialized: boolean; credentialsRequirement: { __typename?: 'CredentialsRequirementType'; password: { @@ -2032,6 +2158,21 @@ export type SubscriptionQuery = { } | null; }; +export type UpdateAccountMutationVariables = Exact<{ + id: Scalars['String']['input']; + input: ManageUserInput; +}>; + +export type UpdateAccountMutation = { + __typename?: 'Mutation'; + updateUser: { + __typename?: 'UserType'; + id: string; + name: string; + email: string; + }; +}; + export type UpdateServerRuntimeConfigsMutationVariables = Exact<{ updates: Scalars['JSONObject']['input']; }>; @@ -2283,6 +2424,11 @@ export type Queries = variables: GetServerRuntimeConfigQueryVariables; response: GetServerRuntimeConfigQuery; } + | { + name: 'getUserByEmailQuery'; + variables: GetUserByEmailQueryVariables; + response: GetUserByEmailQuery; + } | { name: 'getUserFeaturesQuery'; variables: GetUserFeaturesQueryVariables; @@ -2293,6 +2439,11 @@ export type Queries = variables: GetUserQueryVariables; response: GetUserQuery; } + | { + name: 'getUsersCountQuery'; + variables: GetUsersCountQueryVariables; + response: GetUsersCountQuery; + } | { name: 'getWorkspaceFeaturesQuery'; variables: GetWorkspaceFeaturesQueryVariables; @@ -2385,6 +2536,11 @@ export type Queries = }; export type Mutations = + | { + name: 'addToAdminMutation'; + variables: AddToAdminMutationVariables; + response: AddToAdminMutation; + } | { name: 'deleteBlobMutation'; variables: DeleteBlobMutationVariables; @@ -2405,6 +2561,11 @@ export type Mutations = variables: ChangeEmailMutationVariables; response: ChangeEmailMutation; } + | { + name: 'createChangePasswordUrlMutation'; + variables: CreateChangePasswordUrlMutationVariables; + response: CreateChangePasswordUrlMutation; + } | { name: 'changePasswordMutation'; variables: ChangePasswordMutationVariables; @@ -2435,6 +2596,11 @@ export type Mutations = variables: CreateCustomerPortalMutationVariables; response: CreateCustomerPortalMutation; } + | { + name: 'createUserMutation'; + variables: CreateUserMutationVariables; + response: CreateUserMutation; + } | { name: 'createWorkspaceMutation'; variables: CreateWorkspaceMutationVariables; @@ -2445,11 +2611,21 @@ export type Mutations = variables: DeleteAccountMutationVariables; response: DeleteAccountMutation; } + | { + name: 'deleteUserMutation'; + variables: DeleteUserMutationVariables; + response: DeleteUserMutation; + } | { name: 'deleteWorkspaceMutation'; variables: DeleteWorkspaceMutationVariables; response: DeleteWorkspaceMutation; } + | { + name: 'addToEarlyAccessMutation'; + variables: AddToEarlyAccessMutationVariables; + response: AddToEarlyAccessMutation; + } | { name: 'removeEarlyAccessMutation'; variables: RemoveEarlyAccessMutationVariables; @@ -2475,6 +2651,11 @@ export type Mutations = variables: RecoverDocMutationVariables; response: RecoverDocMutation; } + | { + name: 'removeAdminMutation'; + variables: RemoveAdminMutationVariables; + response: RemoveAdminMutation; + } | { name: 'removeAvatarMutation'; variables: RemoveAvatarMutationVariables; @@ -2525,6 +2706,11 @@ export type Mutations = variables: SetWorkspacePublicByIdMutationVariables; response: SetWorkspacePublicByIdMutation; } + | { + name: 'updateAccountMutation'; + variables: UpdateAccountMutationVariables; + response: UpdateAccountMutation; + } | { name: 'updateServerRuntimeConfigsMutation'; variables: UpdateServerRuntimeConfigsMutationVariables; diff --git a/yarn.lock b/yarn.lock index 54ad28595b..5250e3be1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -184,6 +184,7 @@ __metadata: "@radix-ui/react-toggle-group": "npm:^1.1.0" "@radix-ui/react-tooltip": "npm:^1.1.1" "@sentry/react": "npm:^8.9.0" + "@tanstack/react-table": "npm:^8.19.3" class-variance-authority: "npm:^0.7.0" clsx: "npm:^2.1.1" cmdk: "npm:^1.0.0" @@ -14806,6 +14807,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-table@npm:^8.19.3": + version: 8.19.3 + resolution: "@tanstack/react-table@npm:8.19.3" + dependencies: + "@tanstack/table-core": "npm:8.19.3" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10/10a9bd671f7a8ed5cd22120014dcb1e6cccb5387e01c95a810d28ea0d984f26577582197cd736636acdbb4daeb773984ade4614a048ced4d654953bb79b13118 + languageName: node + linkType: hard + +"@tanstack/table-core@npm:8.19.3": + version: 8.19.3 + resolution: "@tanstack/table-core@npm:8.19.3" + checksum: 10/84af3b9088764ad7ed5f563d564fd5fa480de7b688bf1a8756d169c36730bb49bccf7a0904032eb22f674f84eddf4090e73759be88f86179f8523bbb5bf63f26 + languageName: node + linkType: hard + "@taplo/cli@npm:^0.7.0": version: 0.7.0 resolution: "@taplo/cli@npm:0.7.0"