From db4b9dea07211ef340c59de48af95e59f71a6f69 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 19 Oct 2025 20:23:10 +0000 Subject: [PATCH] feat: add admin organisation creation with user --- ...n-organisation-with-user-create-dialog.tsx | 255 ++++++++++++++++++ .../admin+/organisations._index.tsx | 16 +- .../_authenticated+/admin+/users._index.tsx | 10 +- .../template-admin-user-welcome.tsx | 65 +++++ .../email/templates/admin-user-welcome.tsx | 60 +++++ .../admin/send-admin-user-welcome-email.ts | 76 ++++++ .../lib/server-only/user/create-admin-user.ts | 50 ++++ .../lib/server-only/user/reset-password.ts | 4 + .../create-organisation-with-user.ts | 93 +++++++ .../create-organisation-with-user.types.ts | 28 ++ packages/trpc/server/admin-router/router.ts | 2 + 11 files changed, 650 insertions(+), 9 deletions(-) create mode 100644 apps/remix/app/components/dialogs/admin-organisation-with-user-create-dialog.tsx create mode 100644 packages/email/template-components/template-admin-user-welcome.tsx create mode 100644 packages/email/templates/admin-user-welcome.tsx create mode 100644 packages/lib/server-only/admin/send-admin-user-welcome-email.ts create mode 100644 packages/lib/server-only/user/create-admin-user.ts create mode 100644 packages/trpc/server/admin-router/create-organisation-with-user.ts create mode 100644 packages/trpc/server/admin-router/create-organisation-with-user.types.ts diff --git a/apps/remix/app/components/dialogs/admin-organisation-with-user-create-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-with-user-create-dialog.tsx new file mode 100644 index 000000000..62bb59b4f --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-with-user-create-dialog.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import type { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationWithUserRequestSchema } from '@documenso/trpc/server/admin-router/create-organisation-with-user.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminOrganisationWithUserCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZFormSchema = ZCreateOrganisationWithUserRequestSchema.shape.data; + +type TFormSchema = z.infer; + +const CLAIM_OPTIONS = [ + { value: INTERNAL_CLAIM_ID.FREE, label: 'Free' }, + { value: INTERNAL_CLAIM_ID.TEAM, label: 'Team' }, + { value: INTERNAL_CLAIM_ID.ENTERPRISE, label: 'Enterprise' }, +]; + +export const AdminOrganisationWithUserCreateDialog = ({ + trigger, + ...props +}: AdminOrganisationWithUserCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const navigate = useNavigate(); + + const form = useForm({ + resolver: zodResolver(ZFormSchema), + defaultValues: { + organisationName: '', + userEmail: '', + userName: '', + subscriptionClaimId: INTERNAL_CLAIM_ID.FREE, + }, + }); + + const { mutateAsync: createOrganisationWithUser } = + trpc.admin.organisation.createWithUser.useMutation(); + + const onFormSubmit = async (data: TFormSchema) => { + try { + const result = await createOrganisationWithUser({ + data, + }); + + await navigate(`/admin/organisations/${result.organisationId}`); + + setOpen(false); + + toast({ + title: t`Success`, + description: result.isNewUser + ? t`Organisation created and welcome email sent to new user` + : t`Organisation created and existing user added`, + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: t`An error occurred`, + description: + error.message || + t`We encountered an error while creating the organisation. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create Organisation + User + + + + + Create an organisation and add a user as the owner. If the email exists, the existing + user will be linked to the new organisation. + + + + +
+ +
+ ( + + + Organisation Name + + + + + + + )} + /> + + ( + + + User Email + + + + + + + If this email exists, the user will be added to the organisation. Otherwise, + a new user will be created. + + + + + )} + /> + + ( + + + User Name + + + + + + Used only if creating a new user + + + + )} + /> + + ( + + + Subscription Plan + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx index b09d3caae..bb2230d4e 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx @@ -6,6 +6,7 @@ import { useLocation, useSearchParams } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { Input } from '@documenso/ui/primitives/input'; +import { AdminOrganisationWithUserCreateDialog } from '~/components/dialogs/admin-organisation-with-user-create-dialog'; import { SettingsHeader } from '~/components/general/settings-header'; import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table'; @@ -48,12 +49,15 @@ export default function Organisations() { />
- setSearchQuery(e.target.value)} - placeholder={t`Search by organisation ID, name, customer ID or owner email`} - className="mb-4" - /> +
+ setSearchQuery(e.target.value)} + placeholder={t`Search by organisation ID, name, customer ID or owner email`} + className="flex-1" + /> + +
diff --git a/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx index c661d5a91..1245fd38a 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx @@ -2,6 +2,7 @@ import { Trans } from '@lingui/react/macro'; import { findUsers } from '@documenso/lib/server-only/user/get-all-users'; +import { AdminOrganisationWithUserCreateDialog } from '~/components/dialogs/admin-organisation-with-user-create-dialog'; import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table'; import type { Route } from './+types/users._index'; @@ -30,9 +31,12 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp return (
-

- Manage users -

+
+

+ Manage users +

+ +
{ + return ( + <> + + +
+ + Welcome to {organisationName}! + + + + + An administrator has created a Documenso account for you as part of {organisationName}. + + + + + To get started, please set your password by clicking the button below: + + +
+ + + + You can also copy and paste this link into your browser: {resetPasswordLink} (link + expires in 24 hours) + + +
+ +
+ + + If you didn't expect this account or have any questions, please{' '} + + contact support + + . + + +
+
+ + ); +}; diff --git a/packages/email/templates/admin-user-welcome.tsx b/packages/email/templates/admin-user-welcome.tsx new file mode 100644 index 000000000..914c0ee85 --- /dev/null +++ b/packages/email/templates/admin-user-welcome.tsx @@ -0,0 +1,60 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { Body, Container, Head, Html, Img, Preview, Section } from '../components'; +import { useBranding } from '../providers/branding'; +import type { TemplateAdminUserWelcomeProps } from '../template-components/template-admin-user-welcome'; +import { TemplateAdminUserWelcome } from '../template-components/template-admin-user-welcome'; +import { TemplateFooter } from '../template-components/template-footer'; + +export const AdminUserWelcomeTemplate = ({ + resetPasswordLink, + assetBaseUrl = 'http://localhost:3002', + organisationName, +}: TemplateAdminUserWelcomeProps) => { + const { _ } = useLingui(); + const branding = useBranding(); + + const previewText = msg`Set your password for ${organisationName}`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {_(previewText)} + +
+ +
+ {branding.brandingEnabled && branding.brandingLogo ? ( + Branding Logo + ) : ( + Documenso Logo + )} + + +
+
+
+ + + + +
+ + + ); +}; + +export default AdminUserWelcomeTemplate; diff --git a/packages/lib/server-only/admin/send-admin-user-welcome-email.ts b/packages/lib/server-only/admin/send-admin-user-welcome-email.ts new file mode 100644 index 000000000..5849573e0 --- /dev/null +++ b/packages/lib/server-only/admin/send-admin-user-welcome-email.ts @@ -0,0 +1,76 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/core/macro'; +import crypto from 'crypto'; + +import { mailer } from '@documenso/email/mailer'; +import { AdminUserWelcomeTemplate } from '@documenso/email/templates/admin-user-welcome'; +import { prisma } from '@documenso/prisma'; + +import { getI18nInstance } from '../../client-only/providers/i18n-server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email'; +import { ONE_DAY } from '../../constants/time'; +import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; + +export interface SendAdminUserWelcomeEmailOptions { + userId: number; + organisationName: string; +} + +/** + * Send welcome email for admin-created users with password reset link. + * + * Creates a password reset token and sends an email explaining: + * - An administrator created their account + * - They need to set their password + * - The organization they've been added to + * - Support contact if they didn't expect this + */ +export const sendAdminUserWelcomeEmail = async ({ + userId, + organisationName, +}: SendAdminUserWelcomeEmailOptions) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const token = crypto.randomBytes(18).toString('hex'); + + await prisma.passwordResetToken.create({ + data: { + token, + expiry: new Date(Date.now() + ONE_DAY), + userId: user.id, + }, + }); + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`; + + const emailTemplate = createElement(AdminUserWelcomeTemplate, { + assetBaseUrl, + resetPasswordLink, + organisationName, + }); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(emailTemplate), + renderEmailWithI18N(emailTemplate, { plainText: true }), + ]); + + const i18n = await getI18nInstance(); + + return mailer.sendMail({ + to: { + address: user.email, + name: user.name || '', + }, + from: DOCUMENSO_INTERNAL_EMAIL, + subject: i18n._(msg`Welcome to ${organisationName} on Documenso`), + html, + text, + }); +}; diff --git a/packages/lib/server-only/user/create-admin-user.ts b/packages/lib/server-only/user/create-admin-user.ts new file mode 100644 index 000000000..87eea2a2b --- /dev/null +++ b/packages/lib/server-only/user/create-admin-user.ts @@ -0,0 +1,50 @@ +import { hash } from '@node-rs/bcrypt'; +import crypto from 'crypto'; + +import { prisma } from '@documenso/prisma'; + +import { SALT_ROUNDS } from '../../constants/auth'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface CreateAdminUserOptions { + name: string; + email: string; + signature?: string | null; +} + +/** + * Create a user for admin-initiated flows. + * + * Unlike normal signup, this function: + * - Generates a secure random password (user must reset via email verification) + * - Does NOT create a personal organisation (user will be added to real org) + * - Returns the user immediately without side effects + */ +export const createAdminUser = async ({ name, email, signature }: CreateAdminUserOptions) => { + // Generate a secure random password - user will reset via email verification + const randomPassword = crypto.randomBytes(32).toString('hex'); + const hashedPassword = await hash(randomPassword, SALT_ROUNDS); + + const userExists = await prisma.user.findFirst({ + where: { + email: email.toLowerCase(), + }, + }); + + if (userExists) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, { + message: 'User with this email already exists', + }); + } + + const user = await prisma.user.create({ + data: { + name, + email: email.toLowerCase(), + password: hashedPassword, + signature, + }, + }); + + return user; +}; diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts index 99d796e7b..cf5db779d 100644 --- a/packages/lib/server-only/user/reset-password.ts +++ b/packages/lib/server-only/user/reset-password.ts @@ -30,6 +30,7 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP email: true, name: true, password: true, + emailVerified: true, }, }, }, @@ -54,12 +55,15 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP const hashedPassword = await hash(password, SALT_ROUNDS); await prisma.$transaction(async (tx) => { + // Update password and verify email if not already verified + // This allows admin-created users to verify email and set password in one step await tx.user.update({ where: { id: foundToken.userId, }, data: { password: hashedPassword, + emailVerified: foundToken.user.emailVerified || new Date(), }, }); diff --git a/packages/trpc/server/admin-router/create-organisation-with-user.ts b/packages/trpc/server/admin-router/create-organisation-with-user.ts new file mode 100644 index 000000000..bb90c6ea5 --- /dev/null +++ b/packages/trpc/server/admin-router/create-organisation-with-user.ts @@ -0,0 +1,93 @@ +import { OrganisationType } from '@prisma/client'; + +import { sendAdminUserWelcomeEmail } from '@documenso/lib/server-only/admin/send-admin-user-welcome-email'; +import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation'; +import { createAdminUser } from '@documenso/lib/server-only/user/create-admin-user'; +import { internalClaims } from '@documenso/lib/types/subscription'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZCreateOrganisationWithUserRequestSchema, + ZCreateOrganisationWithUserResponseSchema, +} from './create-organisation-with-user.types'; + +export const createOrganisationWithUserRoute = adminProcedure + .input(ZCreateOrganisationWithUserRequestSchema) + .output(ZCreateOrganisationWithUserResponseSchema) + .mutation(async ({ input, ctx }) => { + const { data } = input; + + ctx.logger.info({ + input: { + userEmail: data.userEmail, + organisationName: data.organisationName, + subscriptionClaimId: data.subscriptionClaimId, + }, + }); + + const existingUser = await prisma.user.findFirst({ + where: { + email: data.userEmail.toLowerCase(), + }, + }); + + let userId: number; + let isNewUser: boolean; + + if (existingUser) { + userId = existingUser.id; + isNewUser = false; + + ctx.logger.info({ + message: 'Linking existing user to new organisation', + userId, + }); + } else { + const newUser = await createAdminUser({ + name: data.userName, + email: data.userEmail, + }); + + userId = newUser.id; + isNewUser = true; + + ctx.logger.info({ + message: 'Created new user for organisation', + userId, + }); + } + + const organisation = await createOrganisation({ + userId, + name: data.organisationName, + type: OrganisationType.ORGANISATION, + claim: internalClaims[data.subscriptionClaimId], + }); + + ctx.logger.info({ + message: 'Organisation created successfully', + organisationId: organisation.id, + userId, + isNewUser, + }); + + if (isNewUser) { + await sendAdminUserWelcomeEmail({ + userId, + organisationName: data.organisationName, + }).catch((err) => { + ctx.logger.error({ + message: 'Failed to send welcome email', + error: err, + userId, + }); + }); + } + + return { + organisationId: organisation.id, + userId, + isNewUser, + }; + }); diff --git a/packages/trpc/server/admin-router/create-organisation-with-user.types.ts b/packages/trpc/server/admin-router/create-organisation-with-user.types.ts new file mode 100644 index 000000000..bcbcda085 --- /dev/null +++ b/packages/trpc/server/admin-router/create-organisation-with-user.types.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; + +import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types'; + +export const ZCreateOrganisationWithUserRequestSchema = z.object({ + data: z.object({ + organisationName: ZOrganisationNameSchema, + userEmail: z.string().email().min(1), + userName: z.string().min(1), + subscriptionClaimId: z.nativeEnum(INTERNAL_CLAIM_ID), + }), +}); + +export type TCreateOrganisationWithUserRequest = z.infer< + typeof ZCreateOrganisationWithUserRequestSchema +>; + +export const ZCreateOrganisationWithUserResponseSchema = z.object({ + organisationId: z.string(), + userId: z.number(), + isNewUser: z.boolean(), +}); + +export type TCreateOrganisationWithUserResponse = z.infer< + typeof ZCreateOrganisationWithUserResponseSchema +>; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index c3d2c9b81..cd36e7be3 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,5 +1,6 @@ import { router } from '../trpc'; import { createAdminOrganisationRoute } from './create-admin-organisation'; +import { createOrganisationWithUserRoute } from './create-organisation-with-user'; import { createStripeCustomerRoute } from './create-stripe-customer'; import { createSubscriptionClaimRoute } from './create-subscription-claim'; import { deleteDocumentRoute } from './delete-document'; @@ -27,6 +28,7 @@ export const adminRouter = router({ find: findAdminOrganisationsRoute, get: getAdminOrganisationRoute, create: createAdminOrganisationRoute, + createWithUser: createOrganisationWithUserRoute, update: updateAdminOrganisationRoute, }, organisationMember: {