diff --git a/apps/remix/app/components/dialogs/admin-user-create-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-create-dialog.tsx new file mode 100644 index 000000000..d5200bbaf --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-create-dialog.tsx @@ -0,0 +1,152 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateUserRequestSchema } from '@documenso/trpc/server/admin-router/create-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, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import type { z } from 'zod'; + +export type AdminUserCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZFormSchema = ZCreateUserRequestSchema; + +type TFormSchema = z.infer; + +export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const navigate = useNavigate(); + + const form = useForm({ + resolver: zodResolver(ZFormSchema), + defaultValues: { + email: '', + name: '', + }, + }); + + const { mutateAsync: createUser } = trpc.admin.user.create.useMutation(); + + const onFormSubmit = async (data: TFormSchema) => { + try { + const result = await createUser(data); + + await navigate(`/admin/users/${result.userId}`); + + setOpen(false); + + toast({ + title: t`Success`, + description: t`User created and welcome email sent`, + 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 user. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create User + + + + Create a new user. A welcome email will be sent with a link to set their password. + + + +
+ +
+ ( + + + Email + + + + + + + )} + /> + + ( + + + Name + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx index bc5bdb0a1..f3b76034d 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users._index.tsx @@ -1,6 +1,7 @@ import { findUsers } from '@documenso/lib/server-only/user/get-all-users'; import { Trans } from '@lingui/react/macro'; +import { AdminUserCreateDialog } from '~/components/dialogs/admin-user-create-dialog'; import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table'; import type { Route } from './+types/users._index'; @@ -27,9 +28,13 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp return (
-

- Manage users -

+
+

+ Manage users +

+ + +
diff --git a/packages/app-tests/e2e/admin/users/create-user.spec.ts b/packages/app-tests/e2e/admin/users/create-user.spec.ts new file mode 100644 index 000000000..0b48a79b5 --- /dev/null +++ b/packages/app-tests/e2e/admin/users/create-user.spec.ts @@ -0,0 +1,387 @@ +import { prisma } from '@documenso/prisma'; +import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +/** + * Fill in the create-user dialog and submit it. + * Assumes the dialog trigger is already visible on the page. + */ +const submitCreateUserDialog = async ({ + page, + email, + name, +}: { + page: import('@playwright/test').Page; + email: string; + name: string; +}) => { + await page.getByRole('button', { name: 'Create User' }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.getByLabel('Email').fill(email); + await dialog.getByLabel('Name').fill(name); + + await dialog.getByTestId('dialog-create-user-button').click(); +}; + +// ─── Happy path ────────────────────────────────────────────────────────────── + +test('[ADMIN][CREATE_USER]: admin can create a new user via the dialog', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const newUserEmail = seedTestEmail(); + const newUserName = 'New Created User'; + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + await expect(page.getByRole('heading', { name: 'Manage users' })).toBeVisible(); + + await submitCreateUserDialog({ page, email: newUserEmail, name: newUserName }); + + // After success the dialog closes and we navigate to /admin/users/:id. + await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 }); + + // The user-detail page renders the user's name in the heading. + await expect(page.getByRole('heading', { name: `Manage ${newUserName}'s profile` })).toBeVisible(); + + // The user exists in the database. + const created = await prisma.user.findUnique({ + where: { email: newUserEmail.toLowerCase() }, + }); + + expect(created).not.toBeNull(); + expect(created?.name).toBe(newUserName); +}); + +// ─── emailVerified is set + password is null for admin-created users ──────── + +test('[ADMIN][CREATE_USER]: a newly created user has emailVerified set and no password', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const newUserEmail = seedTestEmail(); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + await submitCreateUserDialog({ + page, + email: newUserEmail, + name: 'Pending Password User', + }); + + // Wait for redirect to confirm the request finished. + await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 }); + + // Admin-created users start with: + // - emailVerified set (the admin vouches for the email) + // - password null (user must set it via the welcome email reset link) + // The "password=null" state hard-blocks login at email-password.ts:101, + // forcing the user through the reset-link flow before they can sign in. + const created = await prisma.user.findUnique({ + where: { email: newUserEmail.toLowerCase() }, + select: { id: true, emailVerified: true, password: true }, + }); + + expect(created, 'user should exist in the database').not.toBeNull(); + expect( + created?.emailVerified, + 'admin-created user should have emailVerified set — admin vouches for the email', + ).not.toBeNull(); + expect( + created?.password, + 'admin-created user must have password=null — they must set one via the welcome reset link', + ).toBeNull(); +}); + +// ─── Welcome email side effect: a PasswordResetToken is issued ─────────────── + +test('[ADMIN][CREATE_USER]: creating a user issues a PasswordResetToken valid for ~24 hours', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const newUserEmail = seedTestEmail(); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + const beforeCreation = Date.now(); + + await submitCreateUserDialog({ + page, + email: newUserEmail, + name: 'Token Recipient', + }); + + await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 }); + + const created = await prisma.user.findUniqueOrThrow({ + where: { email: newUserEmail.toLowerCase() }, + select: { id: true }, + }); + + // The PasswordResetToken is created by an async background job + // (send.admin.user.created.email), so poll until it shows up. + await expect + .poll( + async () => { + const found = await prisma.passwordResetToken.findFirst({ + where: { userId: created.id }, + }); + return found === null ? null : 'found'; + }, + { + message: `PasswordResetToken for user ${created.id} was not created by the welcome-email job in time`, + timeout: 30_000, + intervals: [250, 500, 1000], + }, + ) + .toBe('found'); + + // Now that we know it exists, fetch it with strict types. + const token = await prisma.passwordResetToken.findFirstOrThrow({ + where: { userId: created.id }, + }); + + // Token should be ~24h in the future (allow a generous fudge window). + const expiry = token.expiry.getTime(); + const expectedExpiry = beforeCreation + 24 * 60 * 60 * 1000; + const driftMs = Math.abs(expiry - expectedExpiry); + + // Allow up to 5 minutes of drift (test setup, db round-trips, clock skew, + // plus job scheduling delay). + expect(driftMs, `token expiry should be ~24h from now, drift was ${driftMs}ms`).toBeLessThan(5 * 60 * 1000); + + // The token value should be a non-trivial hex string. + expect(token.token.length).toBeGreaterThanOrEqual(32); + expect(token.token).toMatch(/^[a-f0-9]+$/); +}); + +// ─── Duplicate email is rejected ───────────────────────────────────────────── + +test('[ADMIN][CREATE_USER]: creating a user with an email that already exists is rejected', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + // Seed an existing user whose email we'll collide with. + const { user: existingUser } = await seedUser({ isPersonalOrganisation: true }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + await submitCreateUserDialog({ + page, + email: existingUser.email, + name: 'Collision Attempt', + }); + + // The dialog should stay open OR an error toast should surface. Either way + // we must NOT navigate to a new user detail page. + await page.waitForTimeout(1000); + await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/); + + // The existing user record must not have been mutated by the attempt. + const stillExisting = await prisma.user.findUnique({ + where: { email: existingUser.email }, + select: { id: true, name: true, emailVerified: true }, + }); + + expect(stillExisting?.id).toBe(existingUser.id); + expect(stillExisting?.name).toBe(existingUser.name); + // The seeded user was verified — make sure the failed create didn't + // somehow flip the flag. + expect(stillExisting?.emailVerified).not.toBeNull(); + + // Count of users with this email must still be 1. + const matching = await prisma.user.count({ + where: { email: existingUser.email }, + }); + expect(matching).toBe(1); +}); + +// ─── Validation: empty form ────────────────────────────────────────────────── + +test('[ADMIN][CREATE_USER]: submitting an empty form shows validation errors and does not create a user', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + await page.getByRole('button', { name: 'Create User' }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Submit without filling anything. + await dialog.getByTestId('dialog-create-user-button').click(); + + // Validation errors are surfaced for both required fields. Their presence + // proves react-hook-form's zodResolver blocked the submit before the + // mutation ran, so no DB write could have happened. + await expect(dialog.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true'); + await expect(dialog.getByLabel('Name')).toHaveAttribute('aria-invalid', 'true'); + + // Dialog stays open and we must not have navigated to a user detail page. + await expect(dialog).toBeVisible(); + await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/); +}); + +// ─── Validation: malformed email ───────────────────────────────────────────── + +test('[ADMIN][CREATE_USER]: a malformed email is rejected client-side', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + await page.getByRole('button', { name: 'Create User' }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + const emailInput = dialog.getByLabel('Email'); + + await emailInput.fill('not-an-email'); + await dialog.getByLabel('Name').fill('Some Name'); + + // The Email input is rendered with type="email" and the form does not set + // noValidate, so the browser's native HTML5 constraint validation rejects + // the malformed value and blocks the submit event from ever firing. (As a + // result react-hook-form's zodResolver never runs and `aria-invalid` is + // not flipped to true — the browser is the layer doing the rejection.) We + // assert directly on the input's ValidityState to prove the value is + // recognised as invalid client-side. + await expect(emailInput).toHaveJSProperty('validity.valid', false); + + await dialog.getByTestId('dialog-create-user-button').click(); + + // Dialog stays open and we must not have navigated. + await expect(dialog).toBeVisible(); + await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/); + + // The bogus email is definitely not present in the DB — a targeted check + // on a specific row, not a global count, so it's safe to run in parallel. + const bogus = await prisma.user.findFirst({ + where: { email: 'not-an-email' }, + }); + expect(bogus).toBeNull(); +}); + +// ─── Cancel button closes dialog without creating ─────────────────────────── + +test('[ADMIN][CREATE_USER]: clicking Cancel closes the dialog and does not create a user', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + const newUserEmail = seedTestEmail(); + + await page.getByRole('button', { name: 'Create User' }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Fill in valid data but cancel anyway. + await dialog.getByLabel('Email').fill(newUserEmail); + await dialog.getByLabel('Name').fill('Cancelled User'); + + await dialog.getByRole('button', { name: 'Cancel' }).click(); + + await expect(dialog).not.toBeVisible(); + + // No user was created with that email. + const created = await prisma.user.findUnique({ + where: { email: newUserEmail.toLowerCase() }, + }); + expect(created).toBeNull(); +}); + +// ─── Email is lowercased when stored ───────────────────────────────────────── + +test('[ADMIN][CREATE_USER]: email entered with mixed case is normalised to lowercase', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + // Build a known mixed-case email. + const rawEmail = seedTestEmail(); + const mixedCaseEmail = rawEmail.replace(/^./, (c) => c.toUpperCase()); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/users', + }); + + await submitCreateUserDialog({ + page, + email: mixedCaseEmail, + name: 'Mixed Case Email User', + }); + + await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 }); + + // Look up by lowercased form — that's the canonical storage. + const created = await prisma.user.findUnique({ + where: { email: rawEmail.toLowerCase() }, + select: { id: true, email: true, emailVerified: true }, + }); + + expect(created).not.toBeNull(); + expect(created?.email).toBe(rawEmail.toLowerCase()); + // Verified — admin vouches for the email. Case normalisation must not + // affect verification state. + expect(created?.emailVerified).not.toBeNull(); +}); + +// ─── Access control: non-admin cannot see the Create User affordance ──────── + +test('[ADMIN][CREATE_USER]: non-admin user redirected away from /admin/users and cannot see Create User button', async ({ + page, +}) => { + const { user: nonAdminUser } = await seedUser({ isAdmin: false }); + + await apiSignin({ + page, + email: nonAdminUser.email, + redirectPath: '/admin/users', + }); + + // Non-admins are redirected away from /admin/*; the admin heading must not + // be visible. + await expect(page.getByRole('heading', { name: 'Manage users' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible(); +}); + +test('[ADMIN][CREATE_USER]: unauthenticated user cannot access /admin/users', async ({ page }) => { + // No apiSignin — just navigate directly. + await page.goto('/admin/users'); + + await expect(page).not.toHaveURL(/\/admin\/users$/); + await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible(); +}); diff --git a/packages/email/template-components/template-admin-user-created.tsx b/packages/email/template-components/template-admin-user-created.tsx new file mode 100644 index 000000000..810d27974 --- /dev/null +++ b/packages/email/template-components/template-admin-user-created.tsx @@ -0,0 +1,57 @@ +import { Trans } from '@lingui/react/macro'; + +import { Button, Link, Section, Text } from '../components'; +import { TemplateDocumentImage } from './template-document-image'; + +export type TemplateAdminUserCreatedProps = { + resetPasswordLink: string; + assetBaseUrl: string; +}; + +export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: TemplateAdminUserCreatedProps) => { + return ( + <> + + +
+ + Welcome to Documenso! + + + + An administrator has created a Documenso account for you. + + + + 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-created.tsx b/packages/email/templates/admin-user-created.tsx new file mode 100644 index 000000000..f9caaf799 --- /dev/null +++ b/packages/email/templates/admin-user-created.tsx @@ -0,0 +1,45 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { Body, Container, Head, Html, Img, Preview, Section } from '../components'; +import type { TemplateAdminUserCreatedProps } from '../template-components/template-admin-user-created'; +import { TemplateAdminUserCreated } from '../template-components/template-admin-user-created'; +import { TemplateFooter } from '../template-components/template-footer'; + +export const AdminUserCreatedTemplate = ({ + resetPasswordLink, + assetBaseUrl = 'http://localhost:3002', +}: TemplateAdminUserCreatedProps) => { + const { _ } = useLingui(); + + const previewText = msg`Set your password for Documenso`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {_(previewText)} + +
+ +
+ Documenso Logo + + +
+
+
+ + + + +
+ + + ); +}; + +export default AdminUserCreatedTemplate; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index b2863dcc5..636f66b48 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -1,4 +1,5 @@ import { JobClient } from './client/client'; +import { SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-admin-user-created-email'; import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email'; import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails'; import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email'; @@ -29,6 +30,7 @@ import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-e * triggering jobs. */ export const jobsClient = new JobClient([ + SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION, SEND_SIGNING_EMAIL_JOB_DEFINITION, SEND_CONFIRMATION_EMAIL_JOB_DEFINITION, SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION, diff --git a/packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts b/packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts new file mode 100644 index 000000000..1fd5341ef --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts @@ -0,0 +1,67 @@ +import { mailer } from '@documenso/email/mailer'; +import { AdminUserCreatedTemplate } from '@documenso/email/templates/admin-user-created'; +import { prisma } from '@documenso/prisma'; +import { msg } from '@lingui/core/macro'; +import crypto from 'crypto'; +import { createElement } from 'react'; +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'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSendAdminUserCreatedEmailJobDefinition } from './send-admin-user-created-email'; + +/** + * Send notification 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 + * - Support contact if they didn't expect this + */ +export const run = async ({ payload, io }: { payload: TSendAdminUserCreatedEmailJobDefinition; io: JobRunIO }) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: payload.userId, + }, + }); + + const token = await io.runTask(`create-password-reset-token`, async () => { + const passwordResetToken = await prisma.passwordResetToken.create({ + data: { + token: crypto.randomBytes(18).toString('hex'), + expiry: new Date(Date.now() + ONE_DAY), + userId: user.id, + }, + }); + + return passwordResetToken.token; + }); + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`; + + const emailTemplate = createElement(AdminUserCreatedTemplate, { + assetBaseUrl, + resetPasswordLink, + }); + + 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 Documenso`), + html, + text, + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-admin-user-created-email.ts b/packages/lib/jobs/definitions/emails/send-admin-user-created-email.ts new file mode 100644 index 000000000..cfc16e2df --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-admin-user-created-email.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID = 'send.admin.user.created.email'; + +const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + userId: z.number(), +}); + +export type TSendAdminUserCreatedEmailJobDefinition = z.infer< + typeof SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA +>; + +export const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION = { + id: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID, + name: 'Send Admin User Created Email', + version: '1.0.0', + trigger: { + name: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID, + schema: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-admin-user-created-email.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID, + TSendAdminUserCreatedEmailJobDefinition +>; 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..90cb3d04a --- /dev/null +++ b/packages/lib/server-only/user/create-admin-user.ts @@ -0,0 +1,43 @@ +import { prisma } from '@documenso/prisma'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface CreateAdminUserOptions { + name: string; + email: string; +} + +/** + * Create a user for admin-initiated flows. + * + * Unlike normal signup, this function: + * - Leaves the password unset (`null`); the user must set it later via a password reset/onboarding link + * - Marks the email as verified immediately because this route is only called by admins + * - 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 }: CreateAdminUserOptions) => { + 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: null, + // Verifying the email here instead of the password reset flow to reduce the + // attack surface. This route is only called by admins. + emailVerified: new Date(), + }, + }); + + return user; +}; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index e95bda507..35b48bfe7 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -26,30 +26,41 @@ export const createUser = async ({ name, email, password, signature }: CreateUse throw new AppError(AppErrorCode.ALREADY_EXISTS); } - const user = await prisma.$transaction(async (tx) => { - const user = await tx.user.create({ - data: { - name, - email: email.toLowerCase(), - password: hashedPassword, // Todo: (RR7) Drop password. - signature, - }, - }); - - // Todo: (RR7) Migrate to use this after RR7. - // await tx.account.create({ - // data: { - // userId: user.id, - // type: 'emailPassword', // Todo: (RR7) - // provider: 'DOCUMENSO', // Todo: (RR7) Enums - // providerAccountId: user.id.toString(), - // password: hashedPassword, - // }, - // }); - - return user; + const user = await prisma.user.create({ + data: { + name, + email: email.toLowerCase(), + password: hashedPassword, // Todo: (RR7) Drop password. + signature, + }, }); + // Todo: (RR7) Migrate to use this after RR7. + // Note: If we actually ever proceed with this, there are multiple + // locations where we will need to update this. + // const user = await prisma.$transaction(async (tx) => { + // const user = await tx.user.create({ + // data: { + // name, + // email: email.toLowerCase(), + // password: hashedPassword, // Todo: (RR7) Drop password. + // signature, + // }, + // }); + + // await tx.account.create({ + // data: { + // userId: user.id, + // type: 'emailPassword', // Todo: (RR7) + // provider: 'DOCUMENSO', // Todo: (RR7) Enums + // providerAccountId: user.id.toString(), + // password: hashedPassword, + // }, + // }); + + // return user; + // }); + // Not used at the moment, uncomment if required. await onCreateUserHook(user).catch((err) => { // Todo: (RR7) Add logging. diff --git a/packages/trpc/server/admin-router/create-user.ts b/packages/trpc/server/admin-router/create-user.ts new file mode 100644 index 000000000..f5ab69e03 --- /dev/null +++ b/packages/trpc/server/admin-router/create-user.ts @@ -0,0 +1,32 @@ +import { jobsClient } from '@documenso/lib/jobs/client'; +import { createAdminUser } from '@documenso/lib/server-only/user/create-admin-user'; + +import { adminProcedure } from '../trpc'; +import { ZCreateUserRequestSchema, ZCreateUserResponseSchema } from './create-user.types'; + +export const createUserRoute = adminProcedure + .input(ZCreateUserRequestSchema) + .output(ZCreateUserResponseSchema) + .mutation(async ({ input, ctx }) => { + const { email, name } = input; + + const user = await createAdminUser({ + name, + email, + }); + + ctx.logger.info({ + createdUserId: user.id, + }); + + await jobsClient.triggerJob({ + name: 'send.admin.user.created.email', + payload: { + userId: user.id, + }, + }); + + return { + userId: user.id, + }; + }); diff --git a/packages/trpc/server/admin-router/create-user.types.ts b/packages/trpc/server/admin-router/create-user.types.ts new file mode 100644 index 000000000..6e1b65438 --- /dev/null +++ b/packages/trpc/server/admin-router/create-user.types.ts @@ -0,0 +1,15 @@ +import { ZNameSchema } from '@documenso/lib/constants/auth'; +import { z } from 'zod'; + +export const ZCreateUserRequestSchema = z.object({ + email: z.string().email().min(1), + name: ZNameSchema, +}); + +export type TCreateUserRequest = z.infer; + +export const ZCreateUserResponseSchema = z.object({ + userId: z.number(), +}); + +export type TCreateUserResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7efc6de70..70fceb307 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -2,6 +2,7 @@ import { router } from '../trpc'; import { createAdminOrganisationRoute } from './create-admin-organisation'; import { createStripeCustomerRoute } from './create-stripe-customer'; import { createSubscriptionClaimRoute } from './create-subscription-claim'; +import { createUserRoute } from './create-user'; import { deleteDocumentRoute } from './delete-document'; import { deleteOrganisationRoute } from './delete-organisation'; import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member'; @@ -64,6 +65,7 @@ export const adminRouter = router({ }, user: { get: getUserRoute, + create: createUserRoute, update: updateUserRoute, delete: deleteUserRoute, enable: enableUserRoute,