From df33fbf91b3d74b63c08de62dce3e0d3ac910bdb Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Mon, 30 Dec 2024 05:45:33 +0200 Subject: [PATCH] feat: admin ui for disabling users (#1547) --- .../admin/users/[id]/delete-user-dialog.tsx | 5 +- .../admin/users/[id]/disable-user-dialog.tsx | 141 ++++++++++++++++++ .../admin/users/[id]/enable-user-dialog.tsx | 130 ++++++++++++++++ .../app/(dashboard)/admin/users/[id]/page.tsx | 8 +- packages/api/v1/middleware/authenticated.ts | 24 ++- .../next-auth/get-server-component-session.ts | 4 + packages/lib/server-only/user/disable-user.ts | 69 +++++++++ packages/lib/server-only/user/enable-user.ts | 27 ++++ packages/trpc/server/admin-router/router.ts | 43 +++++- packages/trpc/server/admin-router/schema.ts | 13 +- 10 files changed, 451 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/admin/users/[id]/disable-user-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/users/[id]/enable-user-dialog.tsx create mode 100644 packages/lib/server-only/user/disable-user.ts create mode 100644 packages/lib/server-only/user/enable-user.ts diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx index 011790eb8..560172c77 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx @@ -30,8 +30,8 @@ export type DeleteUserDialogProps = { }; export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => { - const { toast } = useToast(); const { _ } = useLingui(); + const { toast } = useToast(); const router = useRouter(); @@ -44,7 +44,6 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => try { await deleteUser({ id: user.id, - email, }); toast({ @@ -78,7 +77,7 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => return (
diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/disable-user-dialog.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/disable-user-dialog.tsx new file mode 100644 index 000000000..aac003c82 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/disable-user-dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { User } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DisableUserDialogProps = { + className?: string; + userToDisable: User; +}; + +export const DisableUserDialog = ({ className, userToDisable }: DisableUserDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [email, setEmail] = useState(''); + + const { mutateAsync: disableUser, isLoading: isDisablingUser } = + trpc.admin.disableUser.useMutation(); + + const onDisableAccount = async () => { + try { + await disableUser({ + id: userToDisable.id, + }); + + toast({ + title: _(msg`Account disabled`), + description: _(msg`The account has been disabled successfully.`), + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not authorized to disable this user.`) + .otherwise(() => msg`An error occurred while disabling the user.`); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( +
+ +
+ Disable Account + + + Disabling the user results in the user not being able to use the account. It also + disables all the related contents such as subscription, webhooks, teams, and API keys. + + +
+ +
+ + + + + + + + + Disable Account + + + + + + This action is reversible, but please be careful as the account may be + affected permanently (e.g. their settings and contents not being restored + properly). + + + + + +
+ + + To confirm, please enter the accounts email address
({userToDisable.email} + ). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/enable-user-dialog.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/enable-user-dialog.tsx new file mode 100644 index 000000000..cdb5ed2de --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/enable-user-dialog.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { User } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnableUserDialogProps = { + className?: string; + userToEnable: User; +}; + +export const EnableUserDialog = ({ className, userToEnable }: EnableUserDialogProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + + const [email, setEmail] = useState(''); + + const { mutateAsync: enableUser, isLoading: isEnablingUser } = + trpc.admin.enableUser.useMutation(); + + const onEnableAccount = async () => { + try { + await enableUser({ + id: userToEnable.id, + }); + + toast({ + title: _(msg`Account enabled`), + description: _(msg`The account has been enabled successfully.`), + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not authorized to enable this user.`) + .otherwise(() => msg`An error occurred while enabling the user.`); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( +
+ +
+ Enable Account + + + Enabling the account results in the user being able to use the account again, and all + the related features such as webhooks, teams, and API keys for example. + + +
+ +
+ + + + + + + + + Enable Account + + + +
+ + + To confirm, please enter the accounts email address
({userToEnable.email} + ). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 0b67022c4..371726de1 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -23,6 +23,8 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { DeleteUserDialog } from './delete-user-dialog'; +import { DisableUserDialog } from './disable-user-dialog'; +import { EnableUserDialog } from './enable-user-dialog'; import { MultiSelectRoleCombobox } from './multiselect-role-combobox'; const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true }); @@ -153,7 +155,11 @@ export default function UserPage({ params }: { params: { id: number } }) {
- {user && } +
+ {user && } + {user && user.disabled && } + {user && !user.disabled && } +
); } diff --git a/packages/api/v1/middleware/authenticated.ts b/packages/api/v1/middleware/authenticated.ts index dd7f5562e..7f62706ca 100644 --- a/packages/api/v1/middleware/authenticated.ts +++ b/packages/api/v1/middleware/authenticated.ts @@ -1,5 +1,6 @@ import type { NextApiRequest } from 'next'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; import type { Team, User } from '@documenso/prisma/client'; @@ -22,18 +23,33 @@ export const authenticatedMiddleware = < const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0); if (!token) { - throw new Error('Token was not provided for authenticated middleware'); + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'API token was not provided', + }); } const apiToken = await getApiTokenByToken({ token }); + if (apiToken.user.disabled) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'User is disabled', + }); + } + return await handler(args, apiToken.user, apiToken.team); - } catch (_err) { - console.log({ _err }); + } catch (err) { + console.log({ err: err }); + + let message = 'Unauthorized'; + + if (err instanceof AppError) { + message = err.message; + } + return { status: 401, body: { - message: 'Unauthorized', + message, }, } as const; } diff --git a/packages/lib/next-auth/get-server-component-session.ts b/packages/lib/next-auth/get-server-component-session.ts index 7e35af5ad..193eefabf 100644 --- a/packages/lib/next-auth/get-server-component-session.ts +++ b/packages/lib/next-auth/get-server-component-session.ts @@ -21,6 +21,10 @@ export const getServerComponentSession = cache(async () => { }, }); + if (user.disabled) { + return { user: null, session: null }; + } + return { user, session }; }); diff --git a/packages/lib/server-only/user/disable-user.ts b/packages/lib/server-only/user/disable-user.ts new file mode 100644 index 000000000..787b70422 --- /dev/null +++ b/packages/lib/server-only/user/disable-user.ts @@ -0,0 +1,69 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +export type DisableUserOptions = { + id: number; +}; + +export const disableUser = async ({ id }: DisableUserOptions) => { + const user = await prisma.user.findFirst({ + where: { + id, + }, + include: { + ApiToken: true, + Webhooks: true, + passkeys: true, + VerificationToken: true, + PasswordResetToken: true, + }, + }); + + if (!user) { + throw new AppError('There was an error disabling the user'); + } + + try { + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id }, + data: { disabled: true }, + }); + + await tx.apiToken.updateMany({ + where: { userId: id }, + data: { + expires: new Date(), + }, + }); + + await tx.webhook.updateMany({ + where: { userId: id }, + data: { + enabled: false, + }, + }); + + await tx.verificationToken.updateMany({ + where: { userId: id }, + data: { + expires: new Date(), + }, + }); + + await tx.passwordResetToken.updateMany({ + where: { userId: id }, + data: { + expiry: new Date(), + }, + }); + + await tx.passkey.deleteMany({ + where: { userId: id }, + }); + }); + } catch (error) { + console.error('Error disabling user', error); + throw error; + } +}; diff --git a/packages/lib/server-only/user/enable-user.ts b/packages/lib/server-only/user/enable-user.ts new file mode 100644 index 000000000..660bfd6fa --- /dev/null +++ b/packages/lib/server-only/user/enable-user.ts @@ -0,0 +1,27 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +export type EnableUserOptions = { + id: number; +}; + +export const enableUser = async ({ id }: EnableUserOptions) => { + const user = await prisma.user.findFirst({ + where: { + id, + }, + }); + + if (!user) { + throw new AppError('There was an error enabling the user'); + } + + await prisma.user.update({ + where: { + id, + }, + data: { + disabled: false, + }, + }); +}; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7f634a97b..32e14cbc1 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,3 +1,4 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient'; @@ -7,6 +8,8 @@ import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document'; import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; +import { disableUser } from '@documenso/lib/server-only/user/disable-user'; +import { enableUser } from '@documenso/lib/server-only/user/enable-user'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { DocumentStatus } from '@documenso/prisma/client'; @@ -15,6 +18,8 @@ import { adminProcedure, router } from '../trpc'; import { ZAdminDeleteDocumentMutationSchema, ZAdminDeleteUserMutationSchema, + ZAdminDisableUserMutationSchema, + ZAdminEnableUserMutationSchema, ZAdminFindDocumentsQuerySchema, ZAdminResealDocumentMutationSchema, ZAdminUpdateProfileMutationSchema, @@ -70,13 +75,43 @@ export const adminRouter = router({ return await sealDocument({ documentId: id, isResealing }); }), + enableUser: adminProcedure.input(ZAdminEnableUserMutationSchema).mutation(async ({ input }) => { + const { id } = input; + + const user = await getUserById({ id }).catch(() => null); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + }); + } + + return await enableUser({ id }); + }), + + disableUser: adminProcedure.input(ZAdminDisableUserMutationSchema).mutation(async ({ input }) => { + const { id } = input; + + const user = await getUserById({ id }).catch(() => null); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + }); + } + + return await disableUser({ id }); + }), + deleteUser: adminProcedure.input(ZAdminDeleteUserMutationSchema).mutation(async ({ input }) => { - const { id, email } = input; + const { id } = input; - const user = await getUserById({ id }); + const user = await getUserById({ id }).catch(() => null); - if (user.email !== email) { - throw new Error('Email does not match'); + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + }); } return await deleteUser({ id }); diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts index ef53fb007..6fc7f5df5 100644 --- a/packages/trpc/server/admin-router/schema.ts +++ b/packages/trpc/server/admin-router/schema.ts @@ -43,11 +43,22 @@ export type TAdminResealDocumentMutationSchema = z.infer; +export const ZAdminEnableUserMutationSchema = z.object({ + id: z.number().min(1), +}); + +export type TAdminEnableUserMutationSchema = z.infer; + +export const ZAdminDisableUserMutationSchema = z.object({ + id: z.number().min(1), +}); + +export type TAdminDisableUserMutationSchema = z.infer; + export const ZAdminDeleteDocumentMutationSchema = z.object({ id: z.number().min(1), reason: z.string(),