Compare commits

..

1 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
db4b9dea07 feat: add admin organisation creation with user 2025-10-19 20:23:10 +00:00
57 changed files with 1267 additions and 1046 deletions

View File

@@ -13,10 +13,6 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
# Find documentation on setting up Microsoft OAuth here:
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""

View File

@@ -27,33 +27,3 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
```
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
## Microsoft OAuth (Azure AD)
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
### Create and configure a new Azure AD application
1. Go to the [Azure Portal](https://portal.azure.com/)
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
3. In the left sidebar, click **App registrations**
4. Click **New registration**
5. Enter a name for your application (e.g., "Documenso")
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
8. Click **Register**
### Configure the application
1. After registration, you'll be taken to the app's overview page
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
3. In the left sidebar, click **Certificates & secrets**
4. Under **Client secrets**, click **New client secret**
5. Add a description and select an expiration period
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
7. In the Documenso environment variables, set the following:
```
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
```

View File

@@ -0,0 +1,255 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationWithUserRequestSchema } from '@documenso/trpc/server/admin-router/create-organisation-with-user.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminOrganisationWithUserCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZFormSchema = ZCreateOrganisationWithUserRequestSchema.shape.data;
type TFormSchema = z.infer<typeof ZFormSchema>;
const CLAIM_OPTIONS = [
{ value: INTERNAL_CLAIM_ID.FREE, label: 'Free' },
{ value: INTERNAL_CLAIM_ID.TEAM, label: 'Team' },
{ value: INTERNAL_CLAIM_ID.ENTERPRISE, label: 'Enterprise' },
];
export const AdminOrganisationWithUserCreateDialog = ({
trigger,
...props
}: AdminOrganisationWithUserCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
organisationName: '',
userEmail: '',
userName: '',
subscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
},
});
const { mutateAsync: createOrganisationWithUser } =
trpc.admin.organisation.createWithUser.useMutation();
const onFormSubmit = async (data: TFormSchema) => {
try {
const result = await createOrganisationWithUser({
data,
});
await navigate(`/admin/organisations/${result.organisationId}`);
setOpen(false);
toast({
title: t`Success`,
description: result.isNewUser
? t`Organisation created and welcome email sent to new user`
: t`Organisation created and existing user added`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description:
error.message ||
t`We encountered an error while creating the organisation. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create Organisation + User</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create Organisation + User</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Create an organisation and add a user as the owner. If the email exists, the existing
user will be linked to the new organisation.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="organisationName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Organisation Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="userEmail"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>User Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormDescription>
<Trans>
If this email exists, the user will be added to the organisation. Otherwise,
a new user will be created.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="userName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>User Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
<Trans>Used only if creating a new user</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscriptionClaimId"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Subscription Plan</Trans>
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Select a plan`} />
</SelectTrigger>
</FormControl>
<SelectContent>
{CLAIM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-with-user-button"
loading={form.formState.isSubmitting}
>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,

View File

@@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
const onFormSubmit = async () => {
try {
await deleteFolder({
folderId: folder.id,
id: folder.id,
});
onOpenChange(false);

View File

@@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
@@ -63,16 +63,12 @@ export const FolderMoveDialog = ({
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) {
return;
}
if (!folder) return;
try {
await moveFolder({
folderId: folder.id,
data: {
id: folder.id,
parentId: targetFolderId || null,
},
});
onOpenChange(false);

View File

@@ -61,6 +61,8 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
@@ -85,11 +87,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
try {
await updateFolder({
folderId: folder.id,
data: {
id: folder.id,
name: data.name,
visibility: data.visibility,
},
visibility: isTeamContext
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
@@ -138,6 +140,7 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
)}
/>
{isTeamContext && (
<FormField
control={form.control}
name="visibility"
@@ -168,6 +171,7 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
</FormItem>
)}
/>
)}
<DialogFooter>
<DialogClose asChild>

View File

@@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,

View File

@@ -70,7 +70,6 @@ export type SignInFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
returnTo?: string;
@@ -80,7 +79,6 @@ export const SignInForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
@@ -97,8 +95,6 @@ export const SignInForm = ({
'totp' | 'backup'
>('totp');
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const redirectPath = useMemo(() => {
@@ -275,22 +271,6 @@ export const SignInForm = ({
}
};
const onSignInWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn({
redirectPath,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignInWithOIDCClick = async () => {
try {
await authClient.oidc.signIn({
@@ -383,7 +363,7 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{hasSocialAuthEnabled && (
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
@@ -407,20 +387,6 @@ export const SignInForm = ({
</Button>
)}
{isMicrosoftSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithMicrosoftClick}
>
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
Microsoft
</Button>
)}
{isOIDCSSOEnabled && (
<Button
type="button"

View File

@@ -66,7 +66,6 @@ export type SignUpFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
};
@@ -74,7 +73,6 @@ export const SignUpForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
}: SignUpFormProps) => {
const { _ } = useLingui();
@@ -86,8 +84,6 @@ export const SignUpForm = ({
const utmSrc = searchParams.get('utm_source') ?? null;
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
@@ -152,20 +148,6 @@ export const SignUpForm = ({
}
};
const onSignUpWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignUpWithOIDCClick = async () => {
try {
await authClient.oidc.signIn();
@@ -245,7 +227,7 @@ export const SignUpForm = ({
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
hasSocialAuthEnabled && 'h-[650px]',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
)}
disabled={isSubmitting}
>
@@ -320,7 +302,7 @@ export const SignUpForm = ({
)}
/>
{hasSocialAuthEnabled && (
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
@@ -348,26 +330,6 @@ export const SignUpForm = ({
</>
)}
{isMicrosoftSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
</>
)}
{isOIDCSSOEnabled && (
<>
<Button

View File

@@ -12,7 +12,6 @@ import {
import { Link } from 'react-router';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -29,15 +28,22 @@ import { useCurrentTeam } from '~/providers/team';
export type FolderCardProps = {
folder: TFolderWithSubfolders;
onMove: (folder: TFolderWithSubfolders) => void;
onPin: (folderId: string) => void;
onUnpin: (folderId: string) => void;
onSettings: (folder: TFolderWithSubfolders) => void;
onDelete: (folder: TFolderWithSubfolders) => void;
};
export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
export const FolderCard = ({
folder,
onMove,
onPin,
onUnpin,
onSettings,
onDelete,
}: FolderCardProps) => {
const team = useCurrentTeam();
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
const formatPath = () => {
const rootPath =
folder.type === FolderType.DOCUMENT
@@ -47,15 +53,6 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
return `${rootPath}/f/${folder.id}`;
};
const updateFolder = async ({ pinned }: { pinned: boolean }) => {
await updateFolderMutation({
folderId: folder.id,
data: {
pinned,
},
});
};
return (
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
@@ -115,7 +112,9 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
<Trans>Move</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
<DropdownMenuItem
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
>
<PinIcon className="mr-2 h-4 w-4" />
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
</DropdownMenuItem>

View File

@@ -34,6 +34,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
type,
parentId,
@@ -152,6 +155,8 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -175,6 +180,8 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);

View File

@@ -6,6 +6,7 @@ import { useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { AdminOrganisationWithUserCreateDialog } from '~/components/dialogs/admin-organisation-with-user-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -48,12 +49,15 @@ export default function Organisations() {
/>
<div className="mt-4">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
className="flex-1"
/>
<AdminOrganisationWithUserCreateDialog />
</div>
<AdminOrganisationsTable />
</div>

View File

@@ -2,6 +2,7 @@ import { Trans } from '@lingui/react/macro';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
import { AdminOrganisationWithUserCreateDialog } from '~/components/dialogs/admin-organisation-with-user-create-dialog';
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
import type { Route } from './+types/users._index';
@@ -30,9 +31,12 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-4xl font-semibold">
<Trans>Manage users</Trans>
</h2>
<AdminOrganisationWithUserCreateDialog />
</div>
<AdminDashboardUsersTable
users={users}

View File

@@ -42,6 +42,9 @@ export default function DocumentsFoldersPage() {
parentId: null,
});
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const navigateToFolder = (folderId?: string | null) => {
const documentsPath = formatDocumentsPath(team.url);
@@ -110,6 +113,8 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -142,6 +147,8 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);

View File

@@ -42,6 +42,9 @@ export default function TemplatesFoldersPage() {
parentId: null,
});
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const navigateToFolder = (folderId?: string | null) => {
const templatesPath = formatTemplatesPath(team.url);
@@ -110,6 +113,8 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -142,6 +147,8 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);

View File

@@ -4,7 +4,6 @@ import { Link, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
@@ -24,7 +23,6 @@ export async function loader({ request }: Route.LoaderArgs) {
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
@@ -34,15 +32,13 @@ export async function loader({ request }: Route.LoaderArgs) {
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData;
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
@@ -58,7 +54,6 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
/>

View File

@@ -1,10 +1,6 @@
import { redirect } from 'react-router';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
} from '@documenso/lib/constants/auth';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { SignUpForm } from '~/components/forms/signup';
@@ -21,7 +17,6 @@ export function loader() {
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
@@ -30,19 +25,17 @@ export function loader() {
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
};
}
export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData;
return (
<SignUpForm
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
/>
);

View File

@@ -4,6 +4,7 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZBaseEmbedAuthoringSchema = z
.object({
token: z.string(),
externalId: z.string().optional(),
features: z
.object({

View File

@@ -103,5 +103,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.13.0"
"version": "1.12.10"
}

View File

@@ -1 +0,0 @@
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#F1511B" d="M121.666 121.666H0V0h121.666z"/><path fill="#80CC28" d="M256 121.666H134.335V0H256z"/><path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z"/><path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z"/></svg>

Before

Width:  |  Height:  |  Size: 356 B

View File

@@ -1,7 +1,6 @@
import { Hono } from 'hono';
import { rateLimiter } from 'hono-rate-limiter';
import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id';
import type { RequestIdVariables } from 'hono/request-id';
import type { Logger } from 'pino';
@@ -84,14 +83,12 @@ app.route('/api/auth', auth);
app.route('/api/files', filesRoute);
// API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_BETA_URL}/*`, cors());
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
export default app;

19
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.13.0",
"version": "1.12.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.13.0",
"version": "1.12.10",
"workspaces": [
"apps/*",
"packages/*"
@@ -19,7 +19,6 @@
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"
@@ -90,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.13.0",
"version": "1.12.10",
"dependencies": {
"@cantoo/pdf-lib": "^2.3.2",
"@documenso/api": "*",
@@ -27199,18 +27198,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf2json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
"license": "Apache-2.0",
"bin": {
"pdf2json": "bin/pdf2json.js"
},
"engines": {
"node": ">=20.18.0"
}
},
"node_modules/pdfjs-dist": {
"version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",

View File

@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.13.0",
"version": "1.12.10",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -74,7 +74,6 @@
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"

View File

@@ -24,7 +24,6 @@ import {
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -327,6 +326,11 @@ test.describe('Document API V2', () => {
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
});
const asdf = await res.json();
console.log({
asdf,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
@@ -403,6 +407,11 @@ test.describe('Document API V2', () => {
headers: { Authorization: `Bearer ${tokenA}` },
});
const asdf = await res.json();
console.log({
asdf,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
@@ -2706,154 +2715,4 @@ test.describe('Document API V2', () => {
expect(res.status()).toBe(200);
});
});
test.describe('Folder list endpoint', () => {
test('should block unauthorized access to folder list endpoint', async ({ request }) => {
await seedBlankFolder(userA, teamA.id);
await seedBlankFolder(userA, teamA.id);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const { data } = await res.json();
expect(data.every((folder: { userId: number }) => folder.userId !== userA.id)).toBe(true);
expect(data.length).toBe(0);
});
test('should allow authorized access to folder list endpoint', async ({ request }) => {
await seedBlankFolder(userA, teamA.id);
await seedBlankFolder(userA, teamA.id);
// Other team folders should not be visible.
await seedBlankFolder(userA, teamB.id);
await seedBlankFolder(userA, teamB.id);
// Other team and user folders should not be visible.
await seedBlankFolder(userB, teamB.id);
await seedBlankFolder(userB, teamB.id);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const { data } = await res.json();
expect(data.length).toBe(2);
expect(data.every((folder: { userId: number }) => folder.userId === userA.id)).toBe(true);
});
});
test.describe('Folder create endpoint', () => {
test('should block unauthorized access to folder create endpoint', async ({ request }) => {
const unauthorizedFolder = await seedBlankFolder(userB, teamB.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
parentId: unauthorizedFolder.id,
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder create endpoint', async ({ request }) => {
const authorizedFolder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
parentId: authorizedFolder.id,
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const noParentRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(noParentRes.ok()).toBeTruthy();
expect(noParentRes.status()).toBe(200);
});
});
test.describe('Folder update endpoint', () => {
test('should block unauthorized access to folder update endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
folderId: folder.id,
data: {
name: 'Updated Folder Name',
},
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder update endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
folderId: folder.id,
data: {
name: 'Updated Folder Name',
},
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Folder delete endpoint', () => {
test('should block unauthorized access to folder delete endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { folderId: folder.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder delete endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { folderId: folder.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
});

View File

@@ -222,22 +222,6 @@ export class AuthClient {
},
};
public microsoft = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.microsoft.$post({
json: { redirectPath },
});
await this.handleError(response);
const data = await response.json();
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
public oidc = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });

View File

@@ -26,16 +26,6 @@ export const GoogleAuthOptions: OAuthClientOptions = {
bypassEmailVerification: false,
};
export const MicrosoftAuthOptions: OAuthClientOptions = {
id: 'microsoft',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/microsoft`,
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
bypassEmailVerification: false,
};
export const OidcAuthOptions: OAuthClientOptions = {
id: 'oidc',
scope: ['openid', 'email', 'profile'],

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
import type { HonoAuthContext } from '../types/context';
@@ -45,11 +45,4 @@ export const callbackRoute = new Hono<HonoAuthContext>()
/**
* Google callback verification.
*/
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
/**
* Microsoft callback verification.
*/
.get('/microsoft', async (c) =>
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
);
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }));

View File

@@ -2,7 +2,7 @@ import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
import type { HonoAuthContext } from '../types/context';
@@ -24,20 +24,6 @@ export const oauthRoute = new Hono<HonoAuthContext>()
redirectPath,
});
})
/**
* Microsoft authorize endpoint.
*/
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: MicrosoftAuthOptions,
redirectPath,
});
})
/**
* OIDC authorize endpoint.
*/

View File

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

View File

@@ -0,0 +1,60 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import type { TemplateAdminUserWelcomeProps } from '../template-components/template-admin-user-welcome';
import { TemplateAdminUserWelcome } from '../template-components/template-admin-user-welcome';
import { TemplateFooter } from '../template-components/template-footer';
export const AdminUserWelcomeTemplate = ({
resetPasswordLink,
assetBaseUrl = 'http://localhost:3002',
organisationName,
}: TemplateAdminUserWelcomeProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Set your password for ${organisationName}`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateAdminUserWelcome
resetPasswordLink={resetPasswordLink}
assetBaseUrl={assetBaseUrl}
organisationName={organisationName}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default AdminUserWelcomeTemplate;

View File

@@ -6,7 +6,6 @@ export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
MICROSOFT: 'Microsoft',
OIDC: 'OIDC',
};
@@ -14,10 +13,6 @@ export const IS_GOOGLE_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
);
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
);
export const IS_OIDC_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&

View File

@@ -0,0 +1,76 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import crypto from 'crypto';
import { mailer } from '@documenso/email/mailer';
import { AdminUserWelcomeTemplate } from '@documenso/email/templates/admin-user-welcome';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { ONE_DAY } from '../../constants/time';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendAdminUserWelcomeEmailOptions {
userId: number;
organisationName: string;
}
/**
* Send welcome email for admin-created users with password reset link.
*
* Creates a password reset token and sends an email explaining:
* - An administrator created their account
* - They need to set their password
* - The organization they've been added to
* - Support contact if they didn't expect this
*/
export const sendAdminUserWelcomeEmail = async ({
userId,
organisationName,
}: SendAdminUserWelcomeEmailOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const token = crypto.randomBytes(18).toString('hex');
await prisma.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_DAY),
userId: user.id,
},
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`;
const emailTemplate = createElement(AdminUserWelcomeTemplate, {
assetBaseUrl,
resetPasswordLink,
organisationName,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate),
renderEmailWithI18N(emailTemplate, { plainText: true }),
]);
const i18n = await getI18nInstance();
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Welcome to ${organisationName} on Documenso`),
html,
text,
});
};

View File

@@ -81,15 +81,11 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
const { user: owner } = envelope;
const completedDocumentEmailAttachments = await Promise.all(
envelope.envelopeItems.map(async (envelopeItem) => {
const file = await getFileServerSide(envelopeItem.documentData);
// Use the envelope title for version 1, and the envelope item title for version 2.
const fileNameToUse =
envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
envelope.envelopeItems.map(async (document) => {
const file = await getFileServerSide(document.documentData);
return {
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(file),
contentType: 'application/pdf',
};

View File

@@ -1,9 +1,7 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
export interface CreateFolderOptions {
@@ -24,27 +22,6 @@ export const createFolder = async ({
// This indirectly verifies whether the user has access to the team.
const settings = await getTeamSettings({ userId, teamId });
if (parentId) {
const parentFolder = await prisma.folder.findFirst({
where: {
id: parentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentFolder.type !== type) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Parent folder type does not match the folder type',
});
}
}
return await prisma.folder.create({
data: {
name,

View File

@@ -1,7 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
@@ -21,9 +20,6 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
teamId,
userId,
}),
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
},
});
@@ -43,7 +39,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
return await prisma.folder.delete({
where: {
id: folder.id,
id: folderId,
},
});
};

View File

@@ -1,117 +0,0 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface FindFoldersInternalOptions {
userId: number;
teamId: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFoldersInternal = async ({
userId,
teamId,
parentId,
type,
}: FindFoldersInternalOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
const whereClause = {
AND: [
{ parentId },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
},
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
folderId: folder.id,
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};

View File

@@ -1,11 +1,9 @@
import type { Prisma } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import type { FindResultResponse } from '../../types/search-params';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface FindFoldersOptions {
@@ -13,48 +11,102 @@ export interface FindFoldersOptions {
teamId: number;
parentId?: string | null;
type?: TFolderType;
page?: number;
perPage?: number;
}
export const findFolders = async ({
userId,
teamId,
parentId,
type,
page = 1,
perPage = 10,
}: FindFoldersOptions) => {
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
const team = await getTeamById({ userId, teamId });
const whereClause: Prisma.FolderWhereInput = {
parentId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
const visibilityFilters = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
const [data, count] = await Promise.all([
const whereClause = {
AND: [
{ parentId },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
},
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
folderId: folder.id,
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
folderId: folder.id,
},
}),
prisma.folder.count({
where: whereClause,
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};

View File

@@ -1,30 +1,51 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface GetFolderByIdOptions {
userId: number;
teamId: number;
folderId: string;
folderId?: string;
type?: TFolderType;
}
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
const team = await getTeamById({ userId, teamId });
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
id: folderId,
...(type ? { type } : {}),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const folder = await prisma.folder.findFirst({
where: whereClause,
});
if (!folder) {

View File

@@ -0,0 +1,89 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
folderId?: string;
parentId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (parentId) {
const parentFolder = await tx.folder.findFirst({
where: {
id: parentId,
userId,
teamId,
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await tx.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await tx.folder.update({
where: {
id: folderId,
},
data: {
parentId,
},
});
});
};

View File

@@ -0,0 +1,40 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface PinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: true,
},
});
};

View File

@@ -0,0 +1,40 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UnpinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: false,
},
});
};

View File

@@ -1,28 +1,28 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility } from '@documenso/prisma/generated/types';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface UpdateFolderOptions {
userId: number;
teamId: number;
teamId?: number;
folderId: string;
data: {
parentId?: string | null;
name?: string;
visibility?: DocumentVisibility;
pinned?: boolean;
};
name: string;
visibility: DocumentVisibility;
type?: TFolderType;
}
export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
const { parentId, name, visibility, pinned } = data;
const team = await getTeamById({ userId, teamId });
export const updateFolder = async ({
userId,
teamId,
folderId,
name,
visibility,
type,
}: UpdateFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
@@ -30,9 +30,7 @@ export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFol
teamId,
userId,
}),
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
type,
},
});
@@ -42,66 +40,17 @@ export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFol
});
}
if (parentId) {
const parentFolder = await prisma.folder.findFirst({
where: {
id: parentId,
team: buildTeamWhereQuery({ teamId, userId }),
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await prisma.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
const effectiveVisibility =
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
return await prisma.folder.update({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
data: {
name,
visibility,
parentId,
pinned,
visibility: effectiveVisibility,
},
});
};

View File

@@ -1,202 +0,0 @@
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
import PDFParser from 'pdf2json';
import { getPageSize } from './get-page-size';
type TextPosition = {
text: string;
x: number;
y: number;
w: number;
};
type CharIndexMapping = {
textPosIndex: number;
};
type PlaceholderInfo = {
placeholder: string;
fieldType: string;
recipient: string;
isRequired: string;
page: number;
// PDF2JSON coordinates (in page units - these are relative to page dimensions)
x: number;
y: number;
width: number;
height: number;
// Page dimensions from PDF2JSON (in page units)
pageWidth: number;
pageHeight: number;
};
/*
Questions for later:
- Does it handle multi-page PDFs?
- What happens with incorrect placeholders? E.g. those containing non-accepted properties.
- The placeholder data is dynamic. How to handle this parsing? Perhaps we need to do it similar to the fieldMeta parsing.
*/
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
return new Promise((resolve, reject) => {
const parser = new PDFParser(null, true);
parser.on('pdfParser_dataError', (errData) => {
reject(errData);
});
parser.on('pdfParser_dataReady', (pdfData) => {
const placeholders: PlaceholderInfo[] = [];
pdfData.Pages.forEach((page, pageIndex) => {
/*
pdf2json returns the PDF page content as an array of characters.
We need to concatenate the characters to get the full text.
We also need to get the position of the text so we can place the placeholders in the correct position.
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
*/
const pageWidth = page.Width;
const pageHeight = page.Height;
let pageText = '';
const textPositions: TextPosition[] = [];
const charIndexToTextPos: CharIndexMapping[] = [];
page.Texts.forEach((text) => {
/*
R is an array that contains objects with each character.
The decodedText contains only the character, without any other information.
textPositions stores each character and its position on the page.
*/
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
for (let i = 0; i < decodedText.length; i++) {
charIndexToTextPos.push({
textPosIndex: textPositions.length,
});
}
pageText += decodedText;
textPositions.push({
text: decodedText,
x: text.x,
y: text.y,
w: text.w || 0,
});
});
const placeholderMatches = pageText.matchAll(/{{([^}]+)}}/g);
for (const match of placeholderMatches) {
const placeholder = match[0];
const placeholderData = match[1].split(',').map((part) => part.trim());
const [fieldType, recipient, isRequired] = placeholderData;
/*
Find the position of where the placeholder starts in the text
Then find the position of where the placeholder ends in the text by adding the length of the placeholder to the index of the placeholder.
*/
const matchIndex = match.index;
const placeholderLength = placeholder.length;
const placeholderEndIndex = matchIndex + placeholderLength;
const startCharInfo = charIndexToTextPos[matchIndex];
const endCharInfo = charIndexToTextPos[placeholderEndIndex - 1];
if (!startCharInfo || !endCharInfo) {
console.error('Could not find text position for placeholder', placeholder);
return;
}
const startTextPos = textPositions[startCharInfo.textPosIndex];
const endTextPos = textPositions[endCharInfo.textPosIndex];
/*
PDF2JSON coordinates - these are in "page units" (relative coordinates)
Calculate width as the distance from start to end, plus a portion of the last character's width
Use 10% of the last character width to avoid extending too far beyond the placeholder
*/
const x = startTextPos.x;
const y = startTextPos.y;
const width = endTextPos.x + endTextPos.w * 0.1 - startTextPos.x;
placeholders.push({
placeholder,
fieldType,
recipient,
isRequired,
page: pageIndex + 1,
x,
y,
width,
height: 1,
pageWidth,
pageHeight,
});
}
});
resolve(placeholders);
});
parser.parseBuffer(pdf);
});
};
export const replacePlaceholdersInPDF = async (pdf: Buffer): Promise<Buffer> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
const pages = pdfDoc.getPages();
for (const placeholder of placeholders) {
const pageIndex = placeholder.page - 1;
const page = pages[pageIndex];
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
/*
Convert PDF2JSON coordinates to pdf-lib coordinates:
PDF2JSON uses relative "page units":
- x, y, width, height are in page units
- Page dimensions (Width, Height) are also in page units
pdf-lib uses absolute points (1 point = 1/72 inch):
- Need to convert from page units to points
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
- Y-axis in PDF2JSON is top-down (origin at top-left)
Conversion formulas:
- x_points = (x / pageWidth) * pdfLibPageWidth
- y_points = pdfLibPageHeight - ((y / pageHeight) * pdfLibPageHeight)
- width_points = (width / pageWidth) * pdfLibPageWidth
- height_points = (height / pageHeight) * pdfLibPageHeight
*/
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
page.drawRectangle({
x: xPoints,
y: yPoints - heightPoints, // Adjust for height since y is at baseline
width: widthPoints,
height: heightPoints,
color: rgb(1, 1, 1),
borderColor: rgb(1, 1, 1),
borderWidth: 2,
});
}
const modifiedPdfBytes = await pdfDoc.save();
return Buffer.from(modifiedPdfBytes);
};

View File

@@ -0,0 +1,50 @@
import { hash } from '@node-rs/bcrypt';
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { SALT_ROUNDS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateAdminUserOptions {
name: string;
email: string;
signature?: string | null;
}
/**
* Create a user for admin-initiated flows.
*
* Unlike normal signup, this function:
* - Generates a secure random password (user must reset via email verification)
* - Does NOT create a personal organisation (user will be added to real org)
* - Returns the user immediately without side effects
*/
export const createAdminUser = async ({ name, email, signature }: CreateAdminUserOptions) => {
// Generate a secure random password - user will reset via email verification
const randomPassword = crypto.randomBytes(32).toString('hex');
const hashedPassword = await hash(randomPassword, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (userExists) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'User with this email already exists',
});
}
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
signature,
},
});
return user;
};

View File

@@ -1,23 +1,22 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
export const getCompletedDocumentsMonthly = async () => {
const qb = kyselyPrisma.$kysely
.selectFrom('Envelope')
.selectFrom('Document')
.select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
fn.count('id').as('count'),
fn
.sum(fn.count('id'))
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
.as('cume_count'),
])
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.groupBy('month')
.orderBy('month', 'desc')
.limit(12);

View File

@@ -30,6 +30,7 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
email: true,
name: true,
password: true,
emailVerified: true,
},
},
},
@@ -54,12 +55,15 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
const hashedPassword = await hash(password, SALT_ROUNDS);
await prisma.$transaction(async (tx) => {
// Update password and verify email if not already verified
// This allows admin-created users to verify email and set password in one step
await tx.user.update({
where: {
id: foundToken.userId,
},
data: {
password: hashedPassword,
emailVerified: foundToken.user.emailVerified || new Date(),
},
});

View File

@@ -127,7 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
});
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;

View File

@@ -0,0 +1,93 @@
import { OrganisationType } from '@prisma/client';
import { sendAdminUserWelcomeEmail } from '@documenso/lib/server-only/admin/send-admin-user-welcome-email';
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { createAdminUser } from '@documenso/lib/server-only/user/create-admin-user';
import { internalClaims } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZCreateOrganisationWithUserRequestSchema,
ZCreateOrganisationWithUserResponseSchema,
} from './create-organisation-with-user.types';
export const createOrganisationWithUserRoute = adminProcedure
.input(ZCreateOrganisationWithUserRequestSchema)
.output(ZCreateOrganisationWithUserResponseSchema)
.mutation(async ({ input, ctx }) => {
const { data } = input;
ctx.logger.info({
input: {
userEmail: data.userEmail,
organisationName: data.organisationName,
subscriptionClaimId: data.subscriptionClaimId,
},
});
const existingUser = await prisma.user.findFirst({
where: {
email: data.userEmail.toLowerCase(),
},
});
let userId: number;
let isNewUser: boolean;
if (existingUser) {
userId = existingUser.id;
isNewUser = false;
ctx.logger.info({
message: 'Linking existing user to new organisation',
userId,
});
} else {
const newUser = await createAdminUser({
name: data.userName,
email: data.userEmail,
});
userId = newUser.id;
isNewUser = true;
ctx.logger.info({
message: 'Created new user for organisation',
userId,
});
}
const organisation = await createOrganisation({
userId,
name: data.organisationName,
type: OrganisationType.ORGANISATION,
claim: internalClaims[data.subscriptionClaimId],
});
ctx.logger.info({
message: 'Organisation created successfully',
organisationId: organisation.id,
userId,
isNewUser,
});
if (isNewUser) {
await sendAdminUserWelcomeEmail({
userId,
organisationName: data.organisationName,
}).catch((err) => {
ctx.logger.error({
message: 'Failed to send welcome email',
error: err,
userId,
});
});
}
return {
organisationId: organisation.id,
userId,
isNewUser,
};
});

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateOrganisationWithUserRequestSchema = z.object({
data: z.object({
organisationName: ZOrganisationNameSchema,
userEmail: z.string().email().min(1),
userName: z.string().min(1),
subscriptionClaimId: z.nativeEnum(INTERNAL_CLAIM_ID),
}),
});
export type TCreateOrganisationWithUserRequest = z.infer<
typeof ZCreateOrganisationWithUserRequestSchema
>;
export const ZCreateOrganisationWithUserResponseSchema = z.object({
organisationId: z.string(),
userId: z.number(),
isNewUser: z.boolean(),
});
export type TCreateOrganisationWithUserResponse = z.infer<
typeof ZCreateOrganisationWithUserResponseSchema
>;

View File

@@ -1,5 +1,6 @@
import { router } from '../trpc';
import { createAdminOrganisationRoute } from './create-admin-organisation';
import { createOrganisationWithUserRoute } from './create-organisation-with-user';
import { createStripeCustomerRoute } from './create-stripe-customer';
import { createSubscriptionClaimRoute } from './create-subscription-claim';
import { deleteDocumentRoute } from './delete-document';
@@ -27,6 +28,7 @@ export const adminRouter = router({
find: findAdminOrganisationsRoute,
get: getAdminOrganisationRoute,
create: createAdminOrganisationRoute,
createWithUser: createOrganisationWithUserRoute,
update: updateAdminOrganisationRoute,
},
organisationMember: {

View File

@@ -86,10 +86,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
},
],
},
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
},
meta,
requestMetadata: ctx.metadata,
});

View File

@@ -37,7 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings ?? undefined,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,

View File

@@ -35,7 +35,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings ?? undefined,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,

View File

@@ -2,26 +2,27 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
import { findFoldersInternal } from '@documenso/lib/server-only/folder/find-folders-internal';
import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-folder-breadcrumbs';
import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id';
import { moveFolder } from '@documenso/lib/server-only/folder/move-folder';
import { pinFolder } from '@documenso/lib/server-only/folder/pin-folder';
import { unpinFolder } from '@documenso/lib/server-only/folder/unpin-folder';
import { updateFolder } from '@documenso/lib/server-only/folder/update-folder';
import { authenticatedProcedure, router } from '../trpc';
import {
ZCreateFolderRequestSchema,
ZCreateFolderResponseSchema,
ZDeleteFolderRequestSchema,
ZFindFoldersInternalRequestSchema,
ZFindFoldersInternalResponseSchema,
ZCreateFolderSchema,
ZDeleteFolderSchema,
ZFindFoldersRequestSchema,
ZFindFoldersResponseSchema,
ZGenericSuccessResponse,
ZGetFoldersResponseSchema,
ZGetFoldersSchema,
ZMoveFolderSchema,
ZPinFolderSchema,
ZSuccessResponseSchema,
ZUpdateFolderRequestSchema,
ZUpdateFolderResponseSchema,
ZUnpinFolderSchema,
ZUpdateFolderSchema,
} from './schema';
export const folderRouter = router({
@@ -42,7 +43,7 @@ export const folderRouter = router({
},
});
const folders = await findFoldersInternal({
const folders = await findFolders({
userId: user.id,
teamId,
parentId,
@@ -65,48 +66,12 @@ export const folderRouter = router({
};
}),
/**
* @public
*/
findFolders: authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/folder',
summary: 'Find folders',
description: 'Find folders based on a search criteria',
tags: ['Folder'],
},
})
.input(ZFindFoldersRequestSchema)
.output(ZFindFoldersResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { parentId, type, page, perPage } = input;
ctx.logger.info({
input: {
parentId,
type,
},
});
return await findFolders({
userId: user.id,
teamId,
parentId,
type,
page,
perPage,
});
}),
/**
* @private
*/
findFoldersInternal: authenticatedProcedure
.input(ZFindFoldersInternalRequestSchema)
.output(ZFindFoldersInternalResponseSchema)
findFolders: authenticatedProcedure
.input(ZFindFoldersRequestSchema)
.output(ZFindFoldersResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { parentId, type } = input;
@@ -118,7 +83,7 @@ export const folderRouter = router({
},
});
const folders = await findFoldersInternal({
const folders = await findFolders({
userId: user.id,
teamId,
parentId,
@@ -142,20 +107,10 @@ export const folderRouter = router({
}),
/**
* @public
* @private
*/
createFolder: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/folder/create',
summary: 'Create new folder',
description: 'Creates a new folder in your team',
tags: ['Folder'],
},
})
.input(ZCreateFolderRequestSchema)
.output(ZCreateFolderResponseSchema)
.input(ZCreateFolderSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { name, parentId, type } = input;
@@ -190,63 +145,126 @@ export const folderRouter = router({
type,
});
return result;
return {
...result,
type,
};
}),
/**
* @public
* @private
*/
updateFolder: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/folder/update',
summary: 'Update folder',
description: 'Updates an existing folder',
tags: ['Folder'],
},
})
.input(ZUpdateFolderRequestSchema)
.output(ZUpdateFolderResponseSchema)
.input(ZUpdateFolderSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { folderId, data } = input;
const { id, name, visibility } = input;
ctx.logger.info({
input: {
folderId,
id,
},
});
const currentFolder = await getFolderById({
userId: user.id,
teamId,
folderId: id,
});
const result = await updateFolder({
userId: user.id,
teamId,
folderId,
data,
folderId: id,
name,
visibility,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @public
* @private
*/
deleteFolder: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/folder/delete',
summary: 'Delete folder',
description: 'Deletes an existing folder',
tags: ['Folder'],
},
})
.input(ZDeleteFolderRequestSchema)
.input(ZDeleteFolderSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
await deleteFolder({
userId: user.id,
teamId,
folderId: id,
});
return ZGenericSuccessResponse;
}),
/**
* @private
*/
moveFolder: authenticatedProcedure.input(ZMoveFolderSchema).mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id, parentId } = input;
ctx.logger.info({
input: {
id,
parentId,
},
});
const currentFolder = await getFolderById({
userId: user.id,
teamId,
folderId: id,
});
if (parentId !== null) {
try {
await getFolderById({
userId: user.id,
teamId,
folderId: parentId,
type: currentFolder.type,
});
} catch (error) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
}
const result = await moveFolder({
userId: user.id,
teamId,
folderId: id,
parentId,
requestMetadata: ctx.metadata,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
*/
pinFolder: authenticatedProcedure.input(ZPinFolderSchema).mutation(async ({ ctx, input }) => {
const { folderId } = input;
ctx.logger.info({
@@ -255,12 +273,53 @@ export const folderRouter = router({
},
});
await deleteFolder({
userId: user.id,
teamId,
const currentFolder = await getFolderById({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
});
return ZGenericSuccessResponse;
const result = await pinFolder({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
*/
unpinFolder: authenticatedProcedure.input(ZUnpinFolderSchema).mutation(async ({ ctx, input }) => {
const { folderId } = input;
ctx.logger.info({
input: {
folderId,
},
});
const currentFolder = await getFolderById({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
});
const result = await unpinFolder({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
});

View File

@@ -1,9 +1,8 @@
import { z } from 'zod';
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
/**
* Required for empty responses since we currently can't 201 requests for our openapi setup.
@@ -12,23 +11,24 @@ import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSche
*/
export const ZSuccessResponseSchema = z.object({
success: z.boolean(),
type: ZFolderTypeSchema.optional(),
});
export const ZGenericSuccessResponse = {
success: true,
} satisfies z.infer<typeof ZSuccessResponseSchema>;
export const ZFolderSchema = FolderSchema.pick({
id: true,
name: true,
userId: true,
teamId: true,
parentId: true,
pinned: true,
createdAt: true,
updatedAt: true,
visibility: true,
type: true,
export const ZFolderSchema = z.object({
id: z.string(),
name: z.string(),
userId: z.number(),
teamId: z.number().nullable(),
parentId: z.string().nullable(),
pinned: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
visibility: z.nativeEnum(DocumentVisibility),
type: ZFolderTypeSchema,
});
export type TFolder = z.infer<typeof ZFolderSchema>;
@@ -51,39 +51,40 @@ export const ZFolderWithSubfoldersSchema = ZFolderSchema.extend({
export type TFolderWithSubfolders = z.infer<typeof ZFolderWithSubfoldersSchema>;
const ZFolderParentIdSchema = z
.string()
.describe(
'The folder ID to place this folder within. Leave empty to place folder at the root level.',
);
export const ZCreateFolderRequestSchema = z.object({
export const ZCreateFolderSchema = z.object({
name: z.string(),
parentId: ZFolderParentIdSchema.optional(),
parentId: z.string().optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'),
data: z.object({
name: z.string().optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z
.nativeEnum(DocumentVisibility)
.optional()
.describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
}),
export const ZUpdateFolderSchema = z.object({
id: z.string(),
name: z.string(),
visibility: z.nativeEnum(DocumentVisibility),
type: ZFolderTypeSchema.optional(),
});
export type TUpdateFolderRequestSchema = z.infer<typeof ZUpdateFolderRequestSchema>;
export type TUpdateFolderSchema = z.infer<typeof ZUpdateFolderSchema>;
export const ZUpdateFolderResponseSchema = ZFolderSchema;
export const ZDeleteFolderSchema = z.object({
id: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZDeleteFolderRequestSchema = z.object({
export const ZMoveFolderSchema = z.object({
id: z.string(),
parentId: z.string().nullable(),
type: ZFolderTypeSchema.optional(),
});
export const ZPinFolderSchema = z.object({
folderId: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZUnpinFolderSchema = z.object({
folderId: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZGetFoldersSchema = z.object({
@@ -100,20 +101,11 @@ export const ZGetFoldersResponseSchema = z.object({
export type TGetFoldersResponse = z.infer<typeof ZGetFoldersResponseSchema>;
export const ZFindFoldersRequestSchema = ZFindSearchParamsSchema.extend({
parentId: z.string().optional().describe('Filter folders by the parent folder ID'),
type: ZFolderTypeSchema.optional().describe('Filter folders by the folder type'),
});
export const ZFindFoldersResponseSchema = ZFindResultResponse.extend({
data: z.array(ZFolderSchema),
});
export const ZFindFoldersInternalRequestSchema = ZFindSearchParamsSchema.extend({
parentId: z.string().nullable().optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZFindFoldersInternalResponseSchema = z.object({
export const ZFindFoldersResponseSchema = z.object({
data: z.array(ZFolderWithSubfoldersSchema),
breadcrumbs: z.array(ZFolderSchema),
type: ZFolderTypeSchema.optional(),

View File

@@ -1,9 +1,9 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { Trans } from '@lingui/react/macro';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isBase64Image } from '@documenso/lib/constants/signatures';