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
+
+
+
+
+
+
+ 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)}
+ />
+
+
+
+
+ Disable account
+
+
+
+
+
+
+
+ );
+};
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
+
+
+
+
+
+
+ Enable Account
+
+
+
+
+
+
+ To confirm, please enter the accounts email address ({userToEnable.email}
+ ).
+
+
+
+ setEmail(e.target.value)}
+ />
+
+
+
+
+ Enable account
+
+
+
+
+
+
+
+ );
+};
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