From 6f35342a83958256abd5af7bf49d235475cecf63 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 19 Aug 2025 06:09:05 +0300 Subject: [PATCH 01/17] 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 }) => { From 231ef9c27e1bd07668d9b0db07129e416d437ac1 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 19 Aug 2025 13:59:03 +0300 Subject: [PATCH 02/17] chore: add support option (#1853) --- .env.example | 2 + .../components/forms/support-ticket-form.tsx | 138 ++++++++++++++++++ .../components/general/org-menu-switcher.tsx | 13 ++ .../_authenticated+/o.$orgUrl.support.tsx | 125 ++++++++++++++++ package-lock.json | 54 ++++++- packages/lib/package.json | 1 + packages/lib/plain/client.ts | 7 + .../server-only/user/submit-support-ticket.ts | 72 +++++++++ packages/trpc/server/profile-router/router.ts | 27 ++++ packages/trpc/server/profile-router/schema.ts | 9 ++ turbo.json | 1 + 11 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 apps/remix/app/components/forms/support-ticket-form.tsx create mode 100644 apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx create mode 100644 packages/lib/plain/client.ts create mode 100644 packages/lib/server-only/user/submit-support-ticket.ts diff --git a/.env.example b/.env.example index 87ad09a63..7b8872b69 100644 --- a/.env.example +++ b/.env.example @@ -136,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" # OPTIONAL: The file to save the logger output to. Will disable stdout if provided. NEXT_PRIVATE_LOGGER_FILE_PATH= +# [[PLAIN SUPPORT]] +NEXT_PRIVATE_PLAIN_API_KEY= diff --git a/apps/remix/app/components/forms/support-ticket-form.tsx b/apps/remix/app/components/forms/support-ticket-form.tsx new file mode 100644 index 000000000..e80f12d21 --- /dev/null +++ b/apps/remix/app/components/forms/support-ticket-form.tsx @@ -0,0 +1,138 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZSupportTicketSchema = z.object({ + subject: z.string().min(3, 'Subject is required'), + message: z.string().min(10, 'Message must be at least 10 characters'), +}); + +type TSupportTicket = z.infer; + +export type SupportTicketFormProps = { + organisationId: string; + teamId?: string | null; + onSuccess?: () => void; + onClose?: () => void; +}; + +export const SupportTicketForm = ({ + organisationId, + teamId, + onSuccess, + onClose, +}: SupportTicketFormProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: submitSupportTicket, isPending } = + trpc.profile.submitSupportTicket.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZSupportTicketSchema), + defaultValues: { + subject: '', + message: '', + }, + }); + + const isLoading = form.formState.isLoading || isPending; + + const onSubmit = async (data: TSupportTicket) => { + const { subject, message } = data; + + try { + await submitSupportTicket({ + subject, + message, + organisationId, + teamId, + }); + + toast({ + title: t`Support ticket created`, + description: t`Your support request has been submitted. We'll get back to you soon!`, + }); + + if (onSuccess) { + onSuccess(); + } + + form.reset(); + } catch (err) { + toast({ + title: t`Failed to create support ticket`, + description: t`An error occurred. Please try again later.`, + variant: 'destructive', + }); + } + }; + + return ( + <> +
+ +
+ ( + + + Subject + + + + + + + )} + /> + + ( + + + Message + + +