mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 23:07:13 +10:00
feat: allow admins to create users (#2082)
This commit is contained in:
@@ -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<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZFormSchema = ZCreateUserRequestSchema;
|
||||
|
||||
type TFormSchema = z.infer<typeof ZFormSchema>;
|
||||
|
||||
export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
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 (
|
||||
<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 User</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create User</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Create a new user. A welcome email will be sent with a link to set their password.</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="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" data-testid="dialog-create-user-button" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Manage users</Trans>
|
||||
</h2>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Manage users</Trans>
|
||||
</h2>
|
||||
|
||||
<AdminUserCreateDialog />
|
||||
</div>
|
||||
|
||||
<AdminDashboardUsersTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Trans>Welcome to Documenso!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>An administrator has created a Documenso account for you.</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="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
href={resetPasswordLink}
|
||||
>
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
<Text className="mt-8 text-center text-slate-400 text-sm italic">
|
||||
<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-slate-400 text-sm">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
|
||||
|
||||
<TemplateAdminUserCreated resetPasswordLink={resetPasswordLink} assetBaseUrl={assetBaseUrl} />
|
||||
</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 AdminUserCreatedTemplate;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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<typeof ZCreateUserRequestSchema>;
|
||||
|
||||
export const ZCreateUserResponseSchema = z.object({
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateUserResponse = z.infer<typeof ZCreateUserResponseSchema>;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user