mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 04:52:41 +10:00
Compare commits
7 Commits
feat/admin
...
feat/chang
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c81ee8f6c | |||
| bdef3ec018 | |||
| 311815063b | |||
| e5bbbab62a | |||
| c98dff12e8 | |||
| 0cb0a708d5 | |||
| 592befe09a |
@ -1,255 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -27,6 +27,7 @@ const ZRadioFieldFormSchema = z
|
||||
.optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
direction: z.enum(['vertical', 'horizontal']).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@ -53,6 +54,7 @@ export type EditorFieldRadioFormProps = {
|
||||
export const EditorFieldRadioForm = ({
|
||||
value = {
|
||||
type: 'radio',
|
||||
direction: 'vertical',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldRadioFormProps) => {
|
||||
@ -64,6 +66,7 @@ export const EditorFieldRadioForm = ({
|
||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
direction: value.direction || 'vertical',
|
||||
},
|
||||
});
|
||||
|
||||
@ -100,6 +103,7 @@ export const EditorFieldRadioForm = ({
|
||||
onValueChange({
|
||||
type: 'radio',
|
||||
...validatedFormValues.data,
|
||||
direction: validatedFormValues.data.direction || 'vertical',
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@ -156,7 +157,12 @@ export const DocumentSigningRadioField = ({
|
||||
{!field.inserted && (
|
||||
<RadioGroup
|
||||
onValueChange={(value) => handleSelectItem(value)}
|
||||
className="z-10 my-0.5 gap-y-1"
|
||||
className={cn(
|
||||
'z-10 my-0.5 gap-1',
|
||||
parsedFieldMeta.direction === 'horizontal'
|
||||
? 'flex flex-row flex-wrap'
|
||||
: 'flex flex-col gap-y-1',
|
||||
)}
|
||||
>
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
@ -181,7 +187,14 @@ export const DocumentSigningRadioField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<RadioGroup className="my-0.5 gap-y-1">
|
||||
<RadioGroup
|
||||
className={cn(
|
||||
'my-0.5 gap-1',
|
||||
parsedFieldMeta.direction === 'horizontal'
|
||||
? 'flex flex-row flex-wrap'
|
||||
: 'flex flex-col gap-y-1',
|
||||
)}
|
||||
>
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
|
||||
@ -6,7 +6,6 @@ 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';
|
||||
|
||||
@ -49,15 +48,12 @@ 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="flex-1"
|
||||
/>
|
||||
<AdminOrganisationWithUserCreateDialog />
|
||||
</div>
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<AdminOrganisationsTable />
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,6 @@ 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';
|
||||
@ -31,12 +30,9 @@ 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>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage users</Trans>
|
||||
</h2>
|
||||
|
||||
<AdminDashboardUsersTable
|
||||
users={users}
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,60 +0,0 @@
|
||||
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;
|
||||
@ -1,76 +0,0 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@ -331,36 +331,75 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
|
||||
}));
|
||||
|
||||
const selected = field.customText.split(',');
|
||||
const direction = meta.data.direction ?? 'vertical';
|
||||
|
||||
const topPadding = 12;
|
||||
const leftRadioPadding = 8;
|
||||
const leftRadioLabelPadding = 12;
|
||||
const radioSpaceY = 13;
|
||||
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const offsetY = index * radioSpaceY + topPadding;
|
||||
if (direction === 'horizontal') {
|
||||
let currentX = leftRadioPadding;
|
||||
let currentY = topPadding;
|
||||
const maxWidth = pageWidth - fieldX - leftRadioPadding * 2;
|
||||
|
||||
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
||||
|
||||
// Draw label.
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
const labelText = item.value.includes('empty-value-') ? '' : item.value;
|
||||
const labelWidth = font.widthOfTextAtSize(labelText, 12);
|
||||
const itemWidth = leftRadioLabelPadding + labelWidth + 16;
|
||||
|
||||
// Draw radio button.
|
||||
radio.addOptionToPage(item.value, page, {
|
||||
x: fieldX + leftRadioPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
if (currentX + itemWidth > maxWidth && index > 0) {
|
||||
currentX = leftRadioPadding;
|
||||
currentY += radioSpaceY;
|
||||
}
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
radio.select(item.value);
|
||||
page.drawText(labelText, {
|
||||
x: fieldX + currentX + leftRadioLabelPadding,
|
||||
y: pageHeight - (fieldY + currentY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
radio.addOptionToPage(item.value, page, {
|
||||
x: fieldX + currentX,
|
||||
y: pageHeight - (fieldY + currentY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
radio.select(item.value);
|
||||
}
|
||||
|
||||
currentX += itemWidth;
|
||||
}
|
||||
} else {
|
||||
for (const [index, item] of (values ?? []).entries()) {
|
||||
const offsetY = index * radioSpaceY + topPadding;
|
||||
|
||||
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
||||
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
radio.addOptionToPage(item.value, page, {
|
||||
x: fieldX + leftRadioPadding,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
radio.select(item.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -203,6 +203,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
|
||||
type: 'radio',
|
||||
label: field.label,
|
||||
values: newValues,
|
||||
direction: radioMeta.direction ?? 'vertical',
|
||||
};
|
||||
|
||||
return meta;
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@ -30,7 +30,6 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
|
||||
email: true,
|
||||
name: true,
|
||||
password: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -55,15 +54,12 @@ 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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -81,6 +81,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
|
||||
});
|
||||
|
||||
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
||||
@ -278,6 +279,7 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
|
||||
values: [{ id: 1, checked: false, value: '' }],
|
||||
required: false,
|
||||
readOnly: false,
|
||||
direction: 'vertical',
|
||||
};
|
||||
|
||||
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
@ -1,28 +0,0 @@
|
||||
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
|
||||
>;
|
||||
@ -1,6 +1,5 @@
|
||||
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';
|
||||
@ -28,7 +27,6 @@ export const adminRouter = router({
|
||||
find: findAdminOrganisationsRoute,
|
||||
get: getAdminOrganisationRoute,
|
||||
create: createAdminOrganisationRoute,
|
||||
createWithUser: createOrganisationWithUserRoute,
|
||||
update: updateAdminOrganisationRoute,
|
||||
},
|
||||
organisationMember: {
|
||||
|
||||
@ -108,8 +108,15 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
|
||||
field.fieldMeta.values.length > 0
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2 py-0.5">
|
||||
<RadioGroup className="gap-y-1">
|
||||
<div className="py-0.5">
|
||||
<RadioGroup
|
||||
className={cn(
|
||||
'gap-1',
|
||||
field.fieldMeta.direction === 'horizontal'
|
||||
? 'flex flex-row flex-wrap'
|
||||
: 'flex flex-col gap-y-1',
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.values.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
|
||||
@ -122,6 +122,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
|
||||
values: [],
|
||||
required: false,
|
||||
readOnly: false,
|
||||
direction: 'vertical',
|
||||
};
|
||||
case FieldType.CHECKBOX:
|
||||
return {
|
||||
|
||||
@ -11,6 +11,13 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
|
||||
export type RadioFieldAdvancedSettingsProps = {
|
||||
@ -35,6 +42,9 @@ export const RadioFieldAdvancedSettings = ({
|
||||
);
|
||||
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
|
||||
const [required, setRequired] = useState(fieldState.required ?? false);
|
||||
const [direction, setDirection] = useState<'vertical' | 'horizontal'>(
|
||||
fieldState.direction ?? 'vertical',
|
||||
);
|
||||
|
||||
const addValue = () => {
|
||||
const newId = values.length > 0 ? Math.max(...values.map((val) => val.id)) + 1 : 1;
|
||||
@ -69,10 +79,19 @@ export const RadioFieldAdvancedSettings = ({
|
||||
const handleToggleChange = (field: keyof RadioFieldMeta, value: string | boolean) => {
|
||||
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
|
||||
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
|
||||
const currentDirection =
|
||||
field === 'direction' && String(value) === 'horizontal' ? 'horizontal' : 'vertical';
|
||||
setReadOnly(readOnly);
|
||||
setRequired(required);
|
||||
setDirection(currentDirection);
|
||||
|
||||
const errors = validateRadioField(String(value), { readOnly, required, values, type: 'radio' });
|
||||
const errors = validateRadioField(String(value), {
|
||||
readOnly,
|
||||
required,
|
||||
values,
|
||||
type: 'radio',
|
||||
direction: currentDirection,
|
||||
});
|
||||
handleErrors(errors);
|
||||
|
||||
handleFieldChange(field, value);
|
||||
@ -97,7 +116,13 @@ export const RadioFieldAdvancedSettings = ({
|
||||
}, [fieldState.values]);
|
||||
|
||||
useEffect(() => {
|
||||
const errors = validateRadioField(undefined, { readOnly, required, values, type: 'radio' });
|
||||
const errors = validateRadioField(undefined, {
|
||||
readOnly,
|
||||
required,
|
||||
values,
|
||||
type: 'radio',
|
||||
direction,
|
||||
});
|
||||
handleErrors(errors);
|
||||
}, [values]);
|
||||
|
||||
@ -116,6 +141,27 @@ export const RadioFieldAdvancedSettings = ({
|
||||
onChange={(e) => handleFieldChange('label', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Direction</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
value={fieldState.direction ?? 'vertical'}
|
||||
onValueChange={(val) => handleToggleChange('direction', val)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
|
||||
<SelectValue placeholder={_(msg`Select direction`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="vertical">
|
||||
<Trans>Vertical</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="horizontal">
|
||||
<Trans>Horizontal</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
|
||||
Reference in New Issue
Block a user