feat: add admin organisation creation with user

This commit is contained in:
Ephraim Atta-Duncan
2025-10-19 20:23:10 +00:00
parent 06cb8b1f23
commit db4b9dea07
11 changed files with 650 additions and 9 deletions

View File

@ -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<DialogPrimitive.DialogProps, 'children'>;
const ZFormSchema = ZCreateOrganisationWithUserRequestSchema.shape.data;
type TFormSchema = z.infer<typeof ZFormSchema>;
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<TFormSchema>({
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 (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create Organisation + User</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create Organisation + User</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Create an organisation and add a user as the owner. If the email exists, the existing
user will be linked to the new organisation.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="organisationName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="userEmail"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>User Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormDescription>
<Trans>
If this email exists, the user will be added to the organisation. Otherwise,
a new user will be created.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="userName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>User Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
<Trans>Used only if creating a new user</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscriptionClaimId"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Subscription Plan</Trans>
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Select a plan`} />
</SelectTrigger>
</FormControl>
<SelectContent>
{CLAIM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-with-user-button"
loading={form.formState.isSubmitting}
>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -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() {
/>
<div className="mt-4">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
className="flex-1"
/>
<AdminOrganisationWithUserCreateDialog />
</div>
<AdminOrganisationsTable />
</div>

View File

@ -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 (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-4xl font-semibold">
<Trans>Manage users</Trans>
</h2>
<AdminOrganisationWithUserCreateDialog />
</div>
<AdminDashboardUsersTable
users={users}

View File

@ -0,0 +1,65 @@
import { Trans } from '@lingui/react/macro';
import { Button, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateAdminUserWelcomeProps = {
resetPasswordLink: string;
assetBaseUrl: string;
organisationName: string;
};
export const TemplateAdminUserWelcome = ({
resetPasswordLink,
assetBaseUrl,
organisationName,
}: TemplateAdminUserWelcomeProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>Welcome to {organisationName}!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>
An administrator has created a Documenso account for you as part of {organisationName}.
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>To get started, please set your password by clicking the button below:</Trans>
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={resetPasswordLink}
>
<Trans>Set Password</Trans>
</Button>
<Text className="mt-8 text-center text-sm italic text-slate-400">
<Trans>
You can also copy and paste this link into your browser: {resetPasswordLink} (link
expires in 24 hours)
</Trans>
</Text>
</Section>
<Section className="mt-8">
<Text className="text-center text-sm text-slate-400">
<Trans>
If you didn't expect this account or have any questions, please{' '}
<Link href="mailto:support@documenso.com" className="text-documenso-500">
contact support
</Link>
.
</Trans>
</Text>
</Section>
</Section>
</>
);
};

View File

@ -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 (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateAdminUserWelcome
resetPasswordLink={resetPasswordLink}
assetBaseUrl={assetBaseUrl}
organisationName={organisationName}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default AdminUserWelcomeTemplate;

View File

@ -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,
});
};

View File

@ -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;
};

View File

@ -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(),
},
});

View File

@ -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,
};
});

View File

@ -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
>;

View File

@ -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: {