From 6f35342a83958256abd5af7bf49d235475cecf63 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 19 Aug 2025 06:09:05 +0300 Subject: [PATCH] feat: reset user 2fa from admin panel (#1943) --- .../admin-user-reset-two-factor-dialog.tsx | 159 ++++++++++++++++++ .../_authenticated+/admin+/users.$id.tsx | 6 +- .../reset-two-factor-authentication.ts | 50 ++++++ .../reset-two-factor-authentication.types.ts | 10 ++ packages/trpc/server/admin-router/router.ts | 4 + 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx create mode 100644 packages/trpc/server/admin-router/reset-two-factor-authentication.ts create mode 100644 packages/trpc/server/admin-router/reset-two-factor-authentication.types.ts diff --git a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx new file mode 100644 index 000000000..f95657d9f --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { User } from '@prisma/client'; +import { useRevalidator } from 'react-router'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +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 AdminUserResetTwoFactorDialogProps = { + className?: string; + user: User; +}; + +export const AdminUserResetTwoFactorDialog = ({ + className, + user, +}: AdminUserResetTwoFactorDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { revalidate } = useRevalidator(); + const [email, setEmail] = useState(''); + const [open, setOpen] = useState(false); + + const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } = + trpc.admin.user.resetTwoFactor.useMutation(); + + const onResetTwoFactor = async () => { + try { + await resetTwoFactor({ + userId: user.id, + }); + + toast({ + title: _(msg`2FA Reset`), + description: _(msg`The user's two factor authentication has been reset successfully.`), + duration: 5000, + }); + + await revalidate(); + setOpen(false); + } 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 reset two factor authentcation for this user.`, + ) + .otherwise( + () => msg`An error occurred while resetting two factor authentication for the user.`, + ); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + + if (!newOpen) { + setEmail(''); + } + }; + + return ( +
+ +
+ Reset Two Factor Authentication + + + Reset the users two factor authentication. This action is irreversible and will + disable two factor authentication for the user. + + +
+ +
+ + + + + + + + + Reset Two Factor Authentication + + + + + + + This action is irreversible. Please ensure you have informed the user before + proceeding. + + + + +
+ + + To confirm, please enter the accounts email address
({user.email}). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx index fb05128a6..458553dae 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx @@ -27,6 +27,7 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog'; import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog'; import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog'; +import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table'; @@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => { /> -
- {user && } +
+ {user && user.twoFactorEnabled && } {user && user.disabled && } {user && !user.disabled && } + {user && }
); diff --git a/packages/trpc/server/admin-router/reset-two-factor-authentication.ts b/packages/trpc/server/admin-router/reset-two-factor-authentication.ts new file mode 100644 index 000000000..ffacebe22 --- /dev/null +++ b/packages/trpc/server/admin-router/reset-two-factor-authentication.ts @@ -0,0 +1,50 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZResetTwoFactorRequestSchema, + ZResetTwoFactorResponseSchema, +} from './reset-two-factor-authentication.types'; + +export const resetTwoFactorRoute = adminProcedure + .input(ZResetTwoFactorRequestSchema) + .output(ZResetTwoFactorResponseSchema) + .mutation(async ({ input, ctx }) => { + const { userId } = input; + + ctx.logger.info({ + input: { + userId, + }, + }); + + return await resetTwoFactor({ userId }); + }); + +export type ResetTwoFactorOptions = { + userId: number; +}; + +export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => { + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + }); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' }); + } + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: false, + twoFactorBackupCodes: null, + twoFactorSecret: null, + }, + }); +}; diff --git a/packages/trpc/server/admin-router/reset-two-factor-authentication.types.ts b/packages/trpc/server/admin-router/reset-two-factor-authentication.types.ts new file mode 100644 index 000000000..497d36ef7 --- /dev/null +++ b/packages/trpc/server/admin-router/reset-two-factor-authentication.types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ZResetTwoFactorRequestSchema = z.object({ + userId: z.number(), +}); + +export const ZResetTwoFactorResponseSchema = z.void(); + +export type TResetTwoFactorRequest = z.infer; +export type TResetTwoFactorResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index aca1a03d6..3487a3d21 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -21,6 +21,7 @@ import { deleteSubscriptionClaimRoute } from './delete-subscription-claim'; import { findAdminOrganisationsRoute } from './find-admin-organisations'; import { findSubscriptionClaimsRoute } from './find-subscription-claims'; import { getAdminOrganisationRoute } from './get-admin-organisation'; +import { resetTwoFactorRoute } from './reset-two-factor-authentication'; import { ZAdminDeleteDocumentMutationSchema, ZAdminDeleteUserMutationSchema, @@ -51,6 +52,9 @@ export const adminRouter = router({ stripe: { createCustomer: createStripeCustomerRoute, }, + user: { + resetTwoFactor: resetTwoFactorRoute, + }, // Todo: migrate old routes findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {