mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 12:32:34 +10:00
Compare commits
10 Commits
v1.13.2
...
feat/expir
| Author | SHA1 | Date | |
|---|---|---|---|
| bdd4e5ae2e | |||
| 0942a9234d | |||
| 53ee4342c7 | |||
| eb5be386ce | |||
| 40d7527036 | |||
| e9f4d0065e | |||
| b1102c8ba4 | |||
| d6bc4bd0ba | |||
| 262d9efdd5 | |||
| e24d00e23e |
@ -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=""
|
||||
|
||||
@ -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>
|
||||
```
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
parentId: targetFolderId || null,
|
||||
},
|
||||
id: folder.id,
|
||||
parentId: targetFolderId || null,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@ -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: {
|
||||
name: data.name,
|
||||
visibility: data.visibility,
|
||||
},
|
||||
id: folder.id,
|
||||
name: data.name,
|
||||
visibility: isTeamContext
|
||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||
: DocumentVisibility.EVERYONE,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -138,36 +140,38 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isTeamContext && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@ -185,10 +185,6 @@ export const OrganisationMemberInviteDialog = ({
|
||||
return 'form';
|
||||
}
|
||||
|
||||
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
|
||||
return 'form';
|
||||
}
|
||||
|
||||
// This is probably going to screw us over in the future.
|
||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
||||
return 'alert';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -9,7 +9,6 @@ export type EmbedAuthenticationRequiredProps = {
|
||||
email?: string;
|
||||
returnTo: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
};
|
||||
@ -18,7 +17,6 @@ export const EmbedAuthenticationRequired = ({
|
||||
email,
|
||||
returnTo,
|
||||
// isGoogleSSOEnabled,
|
||||
// isMicrosoftSSOEnabled,
|
||||
// isOIDCSSOEnabled,
|
||||
// oidcProviderLabel,
|
||||
}: EmbedAuthenticationRequiredProps) => {
|
||||
@ -39,7 +37,6 @@ export const EmbedAuthenticationRequired = ({
|
||||
<SignInForm
|
||||
// Embed currently not supported.
|
||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
// oidcProviderLabel={oidcProviderLabel}
|
||||
className="mt-4"
|
||||
|
||||
@ -37,7 +37,6 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
@ -45,7 +44,6 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
||||
|
||||
export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
@ -57,10 +55,9 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
envelopeId,
|
||||
updatedAt,
|
||||
documentData,
|
||||
recipient,
|
||||
recipient: _recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
@ -324,13 +321,9 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
|
||||
@ -37,7 +37,6 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||
@ -49,7 +48,6 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
documentData: DocumentData;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
@ -64,7 +62,6 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
token,
|
||||
documentId,
|
||||
envelopeId,
|
||||
documentData,
|
||||
recipient,
|
||||
fields,
|
||||
@ -277,17 +274,15 @@ export const EmbedSignDocumentClientPage = ({
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||
|
||||
{allowDocumentRejection && (
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={documentId}
|
||||
token={token}
|
||||
onRejected={onDocumentRejected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
|
||||
@ -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,
|
||||
@ -92,14 +90,11 @@ export const SignInForm = ({
|
||||
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
@ -276,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({
|
||||
@ -318,8 +297,6 @@ export const SignInForm = ({
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
|
||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
@ -386,64 +363,42 @@ export const SignInForm = ({
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
|
||||
{!isEmbeddedRedirect && (
|
||||
<>
|
||||
{hasSocialAuthEnabled && (
|
||||
<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">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
)}
|
||||
{(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">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
)}
|
||||
{isGoogleSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</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"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
{oidcProviderLabel || 'OIDC'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
{isOIDCSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
{oidcProviderLabel || 'OIDC'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
|
||||
@ -66,18 +66,14 @@ export type SignUpFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
export const SignUpForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
returnTo,
|
||||
}: SignUpFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -88,8 +84,6 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
@ -112,7 +106,7 @@ export const SignUpForm = ({
|
||||
signature,
|
||||
});
|
||||
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
await navigate(`/unverified-account`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Registration Successful`),
|
||||
@ -154,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();
|
||||
@ -247,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}
|
||||
>
|
||||
@ -322,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" />
|
||||
@ -350,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
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ExternalLink, PaperclipIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
export type DocumentSigningAttachmentsPopoverProps = {
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const DocumentSigningAttachmentsPopover = ({
|
||||
envelopeId,
|
||||
token,
|
||||
}: DocumentSigningAttachmentsPopoverProps) => {
|
||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||
envelopeId,
|
||||
token,
|
||||
});
|
||||
|
||||
if (!attachments || attachments.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<PaperclipIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Attachments</Trans>{' '}
|
||||
{attachments && attachments.data.length > 0 && (
|
||||
<span className="ml-1">({attachments.data.length})</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-96" align="start">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
<Trans>Attachments</Trans>
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Documents and resources related to this envelope.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{attachments?.data.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.data}
|
||||
title={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2.5">
|
||||
<div className="bg-muted rounded p-2">
|
||||
<PaperclipIcon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
|
||||
{attachment.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
||||
actionVerb = 'sign',
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthAccountProps) => {
|
||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||
const { recipient } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@ -34,10 +34,8 @@ export const DocumentSigningAuthAccount = ({
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
redirectPath: `/signin#email=${email}`,
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
@ -57,28 +55,16 @@ export const DocumentSigningAuthAccount = ({
|
||||
<AlertDescription>
|
||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||
<span>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in as <strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
{/* Todo: Translate */}
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||
in as <strong>{recipient.email}</strong>
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
||||
@ -47,8 +47,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
}: DocumentSigningAuthDialogProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||
const validAuthTypes = availableAuthTypes.filter(
|
||||
@ -169,11 +168,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
match({ documentAuthType: selectedAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{
|
||||
user: P.when(
|
||||
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
||||
),
|
||||
}, // Assume all current auth methods requires them to be logged in.
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
|
||||
@ -37,7 +37,6 @@ export type DocumentSigningAuthContextValue = {
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||
isAuthRedirectRequired: boolean;
|
||||
isDirectTemplate?: boolean;
|
||||
isCurrentlyAuthenticating: boolean;
|
||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||
passkeyData: PasskeyData;
|
||||
@ -66,7 +65,6 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
isDirectTemplate?: boolean;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@ -74,7 +72,6 @@ export interface DocumentSigningAuthProviderProps {
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
recipient: initialRecipient,
|
||||
isDirectTemplate = false,
|
||||
user,
|
||||
children,
|
||||
}: DocumentSigningAuthProviderProps) => {
|
||||
@ -204,7 +201,6 @@ export const DocumentSigningAuthProvider = ({
|
||||
derivedRecipientAccessAuth,
|
||||
derivedRecipientActionAuth,
|
||||
isAuthRedirectRequired,
|
||||
isDirectTemplate,
|
||||
isCurrentlyAuthenticating,
|
||||
setIsCurrentlyAuthenticating,
|
||||
passkeyData,
|
||||
|
||||
@ -32,7 +32,6 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||
@ -232,13 +231,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={document.envelopeId}
|
||||
token={recipient.token}
|
||||
/>
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
</div>
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||
|
||||
@ -19,7 +19,6 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
|
||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
|
||||
@ -32,13 +31,8 @@ const EnvelopeSignerPageRenderer = lazy(
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
recipient,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50">
|
||||
@ -89,10 +83,6 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<div className="w-full">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Paperclip, Plus, X } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentAttachmentsPopoverProps = {
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
url: z.string().url('Must be a valid URL'),
|
||||
});
|
||||
|
||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
|
||||
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
const { mutateAsync: createAttachment, isPending: isCreating } =
|
||||
trpc.envelope.attachment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TAttachmentFormSchema>({
|
||||
resolver: zodResolver(ZAttachmentFormSchema),
|
||||
defaultValues: {
|
||||
label: '',
|
||||
url: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TAttachmentFormSchema) => {
|
||||
try {
|
||||
await createAttachment({
|
||||
envelopeId,
|
||||
data: {
|
||||
label: data.label,
|
||||
data: data.url,
|
||||
},
|
||||
});
|
||||
|
||||
form.reset();
|
||||
|
||||
setIsAdding(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Attachment added successfully.`),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteAttachment = async (id: string) => {
|
||||
try {
|
||||
await deleteAttachment({ id });
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Attachment removed successfully.`),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
|
||||
<span>
|
||||
<Trans>Attachments</Trans>
|
||||
{attachments && attachments.data.length > 0 && (
|
||||
<span className="ml-1">({attachments.data.length})</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-96" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
<Trans>Attachments</Trans>
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Add links to relevant documents or resources.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{attachments && attachments.data.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{attachments?.data.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="border-border flex items-center justify-between rounded-md border p-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||
<a
|
||||
href={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
|
||||
>
|
||||
{attachment.data}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void onDeleteAttachment(attachment.id)}
|
||||
className="ml-2 h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Trans>Add Attachment</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={_(msg`Label`)} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="url" placeholder={_(msg`URL`)} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -223,6 +223,8 @@ export const DocumentEditForm = ({
|
||||
meta: {
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
signingOrder: data.signingOrder,
|
||||
expiryAmount: data.meta.expiryAmount,
|
||||
expiryUnit: data.meta.expiryUnit,
|
||||
},
|
||||
}),
|
||||
|
||||
@ -247,6 +249,8 @@ export const DocumentEditForm = ({
|
||||
meta: {
|
||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||
signingOrder: data.signingOrder,
|
||||
expiryAmount: data.meta.expiryAmount,
|
||||
expiryUnit: data.meta.expiryUnit,
|
||||
},
|
||||
}),
|
||||
|
||||
@ -476,6 +480,17 @@ export const DocumentEditForm = ({
|
||||
recipients={recipients}
|
||||
signingOrder={document.documentMeta?.signingOrder}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
expiryAmount={document.documentMeta?.expiryAmount}
|
||||
expiryUnit={
|
||||
document.documentMeta?.expiryUnit as
|
||||
| 'minutes'
|
||||
| 'hours'
|
||||
| 'days'
|
||||
| 'weeks'
|
||||
| 'months'
|
||||
| null
|
||||
| undefined
|
||||
}
|
||||
fields={fields}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
onAutoSave={onAddSignersFormAutoSave}
|
||||
|
||||
@ -156,6 +156,14 @@ export const DocumentPageViewRecipients = ({
|
||||
</PopoverHover>
|
||||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.EXPIRED && (
|
||||
<Badge variant="warning">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Expired</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{envelope.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
|
||||
@ -22,7 +22,6 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@ -132,8 +131,6 @@ export default function EnvelopeEditorHeader() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<DocumentAttachmentsPopover envelopeId={envelope.id} />
|
||||
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -41,6 +41,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
case RecipientStatusType.REJECTED:
|
||||
classes = 'bg-red-200 text-red-800';
|
||||
break;
|
||||
case RecipientStatusType.EXPIRED:
|
||||
classes = 'bg-orange-200 text-orange-800';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@ -48,13 +48,20 @@ export const StackAvatarsWithTooltip = ({
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
const expiredRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const otherRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
||||
(recipient) =>
|
||||
getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
|
||||
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
|
||||
);
|
||||
|
||||
return [
|
||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||
...expiredRecipients.sort((a, b) => a.id - b.id),
|
||||
...otherRecipients.sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
}),
|
||||
@ -117,6 +124,30 @@ export const StackAvatarsWithTooltip = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expiredRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Expired</Trans>
|
||||
</h1>
|
||||
{expiredRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
|
||||
@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -36,6 +36,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = isDocumentCompleted(row.status);
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
|
||||
const role = recipient?.role;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
@ -87,8 +88,15 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
isExpired,
|
||||
isCurrentTeamDocument,
|
||||
})
|
||||
.with({ isRecipient: true, isExpired: true }, () => (
|
||||
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
|
||||
<Clock className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Expired</Trans>
|
||||
</Button>
|
||||
))
|
||||
.with(
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
() => (
|
||||
|
||||
@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
@ -193,6 +193,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
const isPending = row.status === DocumentStatusEnum.PENDING;
|
||||
const isComplete = isDocumentCompleted(row.status);
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
|
||||
const role = recipient?.role;
|
||||
|
||||
if (!recipient) {
|
||||
@ -230,7 +231,14 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
isExpired,
|
||||
})
|
||||
.with({ isExpired: true }, () => (
|
||||
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
|
||||
<Clock className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Expired</Trans>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
|
||||
@ -9,7 +9,6 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||
@ -123,13 +122,11 @@ export default function DocumentEditPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentAttachmentsPopover envelopeId={document.envelopeId} />
|
||||
|
||||
{document.useLegacyFieldInsertion && (
|
||||
{document.useLegacyFieldInsertion && (
|
||||
<div>
|
||||
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DocumentEditForm
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -8,7 +8,6 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
|
||||
@ -88,8 +87,6 @@ export default function TemplateEditPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
|
||||
<DocumentAttachmentsPopover envelopeId={template.envelopeId} />
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={template.id}
|
||||
directLink={template.directLink}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -95,7 +95,6 @@ export default function DirectTemplatePage() {
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={directTemplateRecipient}
|
||||
isDirectTemplate={true}
|
||||
user={user}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
|
||||
@ -16,6 +16,7 @@ import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envel
|
||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { expireRecipient } from '@documenso/lib/server-only/recipient/expire-recipient';
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
@ -25,6 +26,7 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
|
||||
@ -136,6 +138,13 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
|
||||
const { documentMeta } = document;
|
||||
|
||||
if (isRecipientExpired(recipient)) {
|
||||
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
|
||||
if (expiredRecipient) {
|
||||
throw redirect(`/sign/${token}/expired`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||
throw redirect(`/sign/${token}/rejected`);
|
||||
}
|
||||
@ -239,6 +248,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
if (isRecipientExpired(recipient)) {
|
||||
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
|
||||
if (expiredRecipient) {
|
||||
throw redirect(`/sign/${token}/expired`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRejected) {
|
||||
throw redirect(`/sign/${token}/rejected`);
|
||||
}
|
||||
|
||||
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { Clock8 } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/expired';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const document = await getDocumentAndSenderByToken({
|
||||
token,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (!isRecipientExpired(recipient)) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const recipientReference =
|
||||
recipient.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
recipient.email;
|
||||
|
||||
if (isDocumentAccessValid) {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
recipientReference,
|
||||
truncatedTitle,
|
||||
recipient,
|
||||
};
|
||||
}
|
||||
|
||||
// Don't leak data if access is denied.
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientReference,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SigningExpiredPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const { isDocumentAccessValid, recipientReference, truncatedTitle, recipient } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientReference} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Clock8 className="h-10 w-10 text-orange-500" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Link Expired</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center text-center text-sm text-orange-600">
|
||||
<Trans>This signing link is no longer valid</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||
<Trans>
|
||||
The signing link has expired and can no longer be used to sign the document. Please
|
||||
contact the document sender if you need a new signing link.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{recipient?.expired && (
|
||||
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||
<Trans>
|
||||
Expired on:{' '}
|
||||
{new Date(recipient.expired).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>Return Home</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
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';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@ -27,45 +23,22 @@ 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;
|
||||
|
||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||
|
||||
if (isAuthenticated) {
|
||||
throw redirect(returnTo || '/');
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
const {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
} = loaderData;
|
||||
|
||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||
}, []);
|
||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
@ -81,20 +54,15 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<SignInForm
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
oidcProviderLabel={oidcProviderLabel}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
|
||||
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
<Trans>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
|
||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||
>
|
||||
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</Trans>
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
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 { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@ -17,40 +12,31 @@ export function meta() {
|
||||
return appMetaTags('Sign Up');
|
||||
}
|
||||
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
export function loader() {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
// 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') {
|
||||
throw redirect('/signin');
|
||||
}
|
||||
|
||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = 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}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
@ -30,13 +29,11 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
||||
export function loader() {
|
||||
// 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;
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
};
|
||||
@ -47,8 +44,7 @@ export default function Layout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||
loaderData || {};
|
||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||
|
||||
const error = useRouteError();
|
||||
|
||||
@ -57,7 +53,6 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<EmbedAuthenticationRequired
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
oidcProviderLabel={oidcProviderLabel}
|
||||
email={error.data.email}
|
||||
|
||||
@ -69,6 +69,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
@ -116,13 +117,11 @@ export default function EmbedDirectTemplatePage() {
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={recipient}
|
||||
isDirectTemplate={true}
|
||||
user={user}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
envelopeId={template.envelopeId}
|
||||
updatedAt={template.updatedAt}
|
||||
documentData={template.templateDocumentData}
|
||||
recipient={recipient}
|
||||
|
||||
@ -164,7 +164,6 @@ export default function EmbedSignDocumentPage() {
|
||||
<EmbedSignDocumentClientPage
|
||||
token={token}
|
||||
documentId={document.id}
|
||||
envelopeId={document.envelopeId}
|
||||
documentData={document.documentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
|
||||
@ -103,5 +103,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.13.2"
|
||||
"version": "1.12.10"
|
||||
}
|
||||
|
||||
@ -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 |
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.13.2",
|
||||
"version": "1.12.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.13.2",
|
||||
"version": "1.12.10",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -89,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.13.2",
|
||||
"version": "1.12.10",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.3.2",
|
||||
"@documenso/api": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.13.2",
|
||||
"version": "1.12.10",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@ -427,7 +427,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
globalAccessAuth: body.authOptions?.globalAccessAuth,
|
||||
globalActionAuth: body.authOptions?.globalActionAuth,
|
||||
},
|
||||
attachments: body.attachments,
|
||||
meta: {
|
||||
subject: body.meta.subject,
|
||||
message: body.meta.message,
|
||||
@ -498,7 +497,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
publicDescription,
|
||||
type,
|
||||
meta,
|
||||
attachments,
|
||||
} = body;
|
||||
|
||||
try {
|
||||
@ -570,7 +568,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
publicDescription,
|
||||
},
|
||||
meta,
|
||||
attachments,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
@ -795,7 +792,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
...body.meta,
|
||||
title: body.title,
|
||||
},
|
||||
attachments: body.attachments,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
|
||||
@ -22,7 +22,6 @@ import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
@ -198,15 +197,6 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
description: 'The globalActionAuth property is only available for Enterprise accounts.',
|
||||
}),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||
@ -272,15 +262,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -5,7 +5,6 @@ import { deleteCookie } from 'hono/cookie';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { OAuthClientOptions } from '../../config';
|
||||
@ -178,12 +177,6 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
if (!isValidReturnTo(redirectPath)) {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||
token_endpoint,
|
||||
code,
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Calculate the width and height of a text element.
|
||||
|
||||
@ -13,6 +13,7 @@ export enum RecipientStatusType {
|
||||
WAITING = 'waiting',
|
||||
UNSIGNED = 'unsigned',
|
||||
REJECTED = 'rejected',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
export const getRecipientType = (
|
||||
@ -27,6 +28,10 @@ export const getRecipientType = (
|
||||
return RecipientStatusType.REJECTED;
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||
return RecipientStatusType.EXPIRED;
|
||||
}
|
||||
|
||||
if (
|
||||
recipient.readStatus === ReadStatus.OPENED &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED
|
||||
@ -52,6 +57,10 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
|
||||
return RecipientStatusType.UNSIGNED;
|
||||
}
|
||||
|
||||
if (types.includes(RecipientStatusType.EXPIRED)) {
|
||||
return RecipientStatusType.EXPIRED;
|
||||
}
|
||||
|
||||
if (types.includes(RecipientStatusType.OPENED)) {
|
||||
return RecipientStatusType.OPENED;
|
||||
}
|
||||
|
||||
@ -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') &&
|
||||
|
||||
@ -15,6 +15,7 @@ export const getRecipientsStats = async () => {
|
||||
[SigningStatus.SIGNED]: 0,
|
||||
[SigningStatus.NOT_SIGNED]: 0,
|
||||
[SigningStatus.REJECTED]: 0,
|
||||
[SigningStatus.EXPIRED]: 0,
|
||||
[SendStatus.SENT]: 0,
|
||||
[SendStatus.NOT_SENT]: 0,
|
||||
};
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
createDocumentAuditLogData,
|
||||
diffDocumentMetaChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
@ -37,6 +38,8 @@ export type CreateDocumentMetaOptions = {
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
language?: SupportedLanguageCodes;
|
||||
expiryAmount?: number;
|
||||
expiryUnit?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
@ -59,6 +62,8 @@ export const updateDocumentMeta = async ({
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
language,
|
||||
expiryAmount,
|
||||
expiryUnit,
|
||||
requestMetadata,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
||||
@ -120,9 +125,30 @@ export const updateDocumentMeta = async ({
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
language,
|
||||
expiryAmount,
|
||||
expiryUnit,
|
||||
},
|
||||
});
|
||||
|
||||
if (expiryAmount !== undefined || expiryUnit !== undefined) {
|
||||
const newExpiryDate = calculateRecipientExpiry(
|
||||
upsertedDocumentMeta.expiryAmount,
|
||||
upsertedDocumentMeta.expiryUnit,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
await tx.recipient.updateMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
signingStatus: { not: 'SIGNED' },
|
||||
role: { not: 'CC' },
|
||||
},
|
||||
data: {
|
||||
expired: newExpiryDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||
|
||||
// Create audit logs only for document type envelopes.
|
||||
|
||||
292
packages/lib/server-only/document/create-document-v2.ts
Normal file
292
packages/lib/server-only/document/create-document-v2.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
|
||||
import {
|
||||
DocumentSource,
|
||||
FolderType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
|
||||
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentDataId: string;
|
||||
normalizePdf?: boolean;
|
||||
data: {
|
||||
title: string;
|
||||
externalId?: string;
|
||||
visibility?: DocumentVisibility;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
formValues?: TDocumentFormValues;
|
||||
recipients: TCreateDocumentTemporaryRequest['recipients'];
|
||||
folderId?: string;
|
||||
expiryAmount?: number;
|
||||
expiryUnit?: string;
|
||||
};
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const createDocumentV2 = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentDataId,
|
||||
normalizePdf,
|
||||
data,
|
||||
meta,
|
||||
requestMetadata,
|
||||
}: CreateDocumentOptions) => {
|
||||
const { title, formValues, folderId } = data;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: folderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (normalizePdf) {
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
id: documentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
if (documentData) {
|
||||
const buffer = await getFileServerSide(documentData);
|
||||
|
||||
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(normalizedPdf),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
documentDataId = newDocumentData.id;
|
||||
}
|
||||
}
|
||||
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
globalAccessAuth: data?.globalAccessAuth || [],
|
||||
globalActionAuth: data?.globalActionAuth || [],
|
||||
});
|
||||
|
||||
const recipientsHaveActionAuth = data.recipients?.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (
|
||||
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
|
||||
!team.organisation.organisationClaim.flags.cfr21
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const { teamRole } = await getMemberRoles({
|
||||
teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
|
||||
|
||||
const emailId = meta?.emailId;
|
||||
|
||||
// Validate that the email ID belongs to the organisation.
|
||||
if (emailId) {
|
||||
const email = await prisma.organisationEmail.findFirst({
|
||||
where: {
|
||||
id: emailId,
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId: data.externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
authOptions,
|
||||
visibility,
|
||||
folderId,
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
create: extractDerivedDocumentMeta(settings, {
|
||||
...meta,
|
||||
expiryAmount: data.expiryAmount,
|
||||
expiryUnit: data.expiryUnit,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
(data.recipients || []).map(async (recipient) => {
|
||||
const recipientAuthOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
});
|
||||
|
||||
const expiryDate = calculateRecipientExpiry(
|
||||
data.expiryAmount ?? null,
|
||||
data.expiryUnit ?? null,
|
||||
new Date(), // Calculate from current time
|
||||
);
|
||||
|
||||
await tx.recipient.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
signingStatus:
|
||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||
authOptions: recipientAuthOptions,
|
||||
expired: expiryDate,
|
||||
fields: {
|
||||
createMany: {
|
||||
data: (recipient.fields || []).map((field) => ({
|
||||
documentId: document.id,
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
documentId: document.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
title,
|
||||
source: {
|
||||
type: DocumentSource.DOCUMENT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const createdDocument = await tx.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdDocument) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return createdDocument;
|
||||
});
|
||||
};
|
||||
177
packages/lib/server-only/document/create-document.ts
Normal file
177
packages/lib/server-only/document/create-document.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
|
||||
import type { DocumentVisibility } from '@prisma/client';
|
||||
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
externalId?: string | null;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentDataId: string;
|
||||
formValues?: Record<string, string | number | boolean>;
|
||||
normalizePdf?: boolean;
|
||||
timezone?: string;
|
||||
userTimezone?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
folderId?: string;
|
||||
expiryAmount?: number;
|
||||
expiryUnit?: string;
|
||||
};
|
||||
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
externalId,
|
||||
documentDataId,
|
||||
teamId,
|
||||
normalizePdf,
|
||||
formValues,
|
||||
requestMetadata,
|
||||
timezone,
|
||||
userTimezone,
|
||||
folderId,
|
||||
expiryAmount,
|
||||
expiryUnit,
|
||||
}: CreateDocumentOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
let folderVisibility: DocumentVisibility | undefined;
|
||||
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
visibility: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
folderVisibility = folder.visibility;
|
||||
}
|
||||
|
||||
if (normalizePdf) {
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
id: documentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
if (documentData) {
|
||||
const buffer = await getFileServerSide(documentData);
|
||||
|
||||
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(normalizedPdf),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
documentDataId = newDocumentData.id;
|
||||
}
|
||||
}
|
||||
|
||||
// userTimezone is last because it's always passed in regardless of the organisation/team settings
|
||||
// for uploads from the frontend
|
||||
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
visibility:
|
||||
folderVisibility ??
|
||||
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
create: extractDerivedDocumentMeta(settings, {
|
||||
timezone: timezoneToUse,
|
||||
expiryAmount,
|
||||
expiryUnit,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
documentId: document.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
title,
|
||||
source: {
|
||||
type: DocumentSource.DOCUMENT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const createdDocument = await tx.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdDocument) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return createdDocument;
|
||||
});
|
||||
};
|
||||
@ -26,6 +26,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
@ -210,6 +211,39 @@ export const resendDocument = async ({
|
||||
text,
|
||||
});
|
||||
|
||||
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
|
||||
const previousExpiryDate = recipient.expired;
|
||||
const newExpiryDate = calculateRecipientExpiry(
|
||||
envelope.documentMeta.expiryAmount,
|
||||
envelope.documentMeta.expiryUnit,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
expired: newExpiryDate,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
previousExpiryDate,
|
||||
newExpiryDate,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@ -177,6 +178,24 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
|
||||
const expiryDate = calculateRecipientExpiry(
|
||||
envelope.documentMeta.expiryAmount,
|
||||
envelope.documentMeta.expiryUnit,
|
||||
new Date(), // Calculate from current time
|
||||
);
|
||||
|
||||
await tx.recipient.updateMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
expired: null,
|
||||
},
|
||||
data: {
|
||||
expired: expiryDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type CreateAttachmentOptions = {
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
data: {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const createAttachment = async ({
|
||||
envelopeId,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
}: CreateAttachmentOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId,
|
||||
type: 'link',
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteAttachmentOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.envelopeAttachment.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,38 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type FindAttachmentsByEnvelopeIdOptions = {
|
||||
envelopeId: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findAttachmentsByEnvelopeId = async ({
|
||||
envelopeId,
|
||||
userId,
|
||||
teamId,
|
||||
}: FindAttachmentsByEnvelopeIdOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type FindAttachmentsByTokenOptions = {
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const findAttachmentsByToken = async ({
|
||||
envelopeId,
|
||||
token,
|
||||
}: FindAttachmentsByTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type FindAttachmentsByTeamOptions = {
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findAttachmentsByTeam = async ({
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: FindAttachmentsByTeamOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type UpdateAttachmentOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: { label?: string; data?: string };
|
||||
};
|
||||
|
||||
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
@ -20,7 +20,6 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
|
||||
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@ -59,11 +58,6 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||
folderId?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: TEnvelopeAttachmentType;
|
||||
}>;
|
||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -73,7 +67,6 @@ export const createEnvelope = async ({
|
||||
teamId,
|
||||
normalizePdf,
|
||||
data,
|
||||
attachments,
|
||||
meta,
|
||||
requestMetadata,
|
||||
internalVersion,
|
||||
@ -253,15 +246,6 @@ export const createEnvelope = async ({
|
||||
})),
|
||||
},
|
||||
},
|
||||
envelopeAttachments: {
|
||||
createMany: {
|
||||
data: (attachments || []).map((attachment) => ({
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
type: attachment.type ?? 'link',
|
||||
})),
|
||||
},
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
authOptions,
|
||||
@ -354,7 +338,6 @@ export const createEnvelope = async ({
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeItems: true,
|
||||
envelopeAttachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -25,7 +25,9 @@ import {
|
||||
} from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { isRecipientExpired } from '../../utils/expiry';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
import { expireRecipient } from '../recipient/expire-recipient';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
token: string;
|
||||
@ -115,6 +117,11 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
if (isRecipientExpired(recipient)) {
|
||||
await expireRecipient({ recipientId: recipient.id });
|
||||
throw new Error(`Signing link has expired`);
|
||||
}
|
||||
|
||||
if (field.inserted) {
|
||||
throw new Error(`Field ${fieldId} has already been inserted`);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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([
|
||||
prisma.folder.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
{
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
89
packages/lib/server-only/folder/move-folder.ts
Normal file
89
packages/lib/server-only/folder/move-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
40
packages/lib/server-only/folder/pin-folder.ts
Normal file
40
packages/lib/server-only/folder/pin-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
40
packages/lib/server-only/folder/unpin-folder.ts
Normal file
40
packages/lib/server-only/folder/unpin-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type ExpireRecipientOptions = {
|
||||
recipientId: number;
|
||||
};
|
||||
|
||||
export const expireRecipient = async ({ recipientId }: ExpireRecipientOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
return await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.EXPIRED,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -640,23 +640,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
data: auditLogsToCreate,
|
||||
});
|
||||
|
||||
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId: directTemplateEnvelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (templateAttachments.length > 0) {
|
||||
await tx.envelopeAttachment.createMany({
|
||||
data: templateAttachments.map((attachment) => ({
|
||||
envelopeId: createdEnvelope.id,
|
||||
type: attachment.type,
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Send email to template owner.
|
||||
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
||||
recipientName: directRecipientEmail,
|
||||
|
||||
@ -52,6 +52,7 @@ import {
|
||||
} from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
@ -91,12 +92,6 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
envelopeItemId?: string;
|
||||
}[];
|
||||
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: 'link';
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Values that will override the predefined values in the template.
|
||||
*/
|
||||
@ -116,6 +111,8 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
expiryAmount?: number;
|
||||
expiryUnit?: string;
|
||||
};
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -301,7 +298,6 @@ export const createDocumentFromTemplate = async ({
|
||||
requestMetadata,
|
||||
folderId,
|
||||
prefillFields,
|
||||
attachments,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
@ -515,6 +511,16 @@ export const createDocumentFromTemplate = async ({
|
||||
data: finalRecipients.map((recipient) => {
|
||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
||||
|
||||
// Calculate expiry date based on override
|
||||
// Note: Templates no longer have default expiry settings (TemplateMeta removed)
|
||||
const expiryAmount = override?.expiryAmount ?? null;
|
||||
const expiryUnit = override?.expiryUnit ?? null;
|
||||
const recipientExpiryDate = calculateRecipientExpiry(
|
||||
expiryAmount,
|
||||
expiryUnit,
|
||||
new Date(), // Calculate from current time
|
||||
);
|
||||
|
||||
return {
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
@ -530,6 +536,7 @@ export const createDocumentFromTemplate = async ({
|
||||
? SigningStatus.SIGNED
|
||||
: SigningStatus.NOT_SIGNED,
|
||||
signingOrder: recipient.signingOrder,
|
||||
expired: recipientExpiryDate,
|
||||
token: recipient.token,
|
||||
};
|
||||
}),
|
||||
@ -674,33 +681,6 @@ export const createDocumentFromTemplate = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
const attachmentsToCreate = [
|
||||
...templateAttachments.map((attachment) => ({
|
||||
envelopeId: envelope.id,
|
||||
type: attachment.type,
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
...(attachments || []).map((attachment) => ({
|
||||
envelopeId: envelope.id,
|
||||
type: attachment.type || 'link',
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
];
|
||||
|
||||
if (attachmentsToCreate.length > 0) {
|
||||
await tx.envelopeAttachment.createMany({
|
||||
data: attachmentsToCreate,
|
||||
});
|
||||
}
|
||||
|
||||
const createdEnvelope = await tx.envelope.findFirst({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -267,6 +267,11 @@ msgstr "{prefix} hat das Dokument erstellt"
|
||||
msgid "{prefix} deleted the document"
|
||||
msgstr "{prefix} hat das Dokument gelöscht"
|
||||
|
||||
#. placeholder {0}: data.data.recipientEmail
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} extended expiry for {0}"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} moved the document to team"
|
||||
msgstr "{prefix} hat das Dokument ins Team verschoben"
|
||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
||||
msgid "Assisting"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "at"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
||||
msgstr "Zeitüberschreitung überschritten"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
msgid "Expired"
|
||||
msgstr "Abgelaufen"
|
||||
|
||||
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Expired on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
@ -4958,6 +4976,11 @@ msgstr "Link läuft in 1 Stunde ab."
|
||||
msgid "Link expires in 30 minutes."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Link Expiry"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||
msgid "Link template"
|
||||
msgstr "Vorlage verlinken"
|
||||
@ -4979,6 +5002,11 @@ msgstr ""
|
||||
msgid "Links Generated"
|
||||
msgstr "Links generiert"
|
||||
|
||||
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Links will expire on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Listening to {0}"
|
||||
@ -5967,6 +5995,10 @@ msgstr "Persönliches Konto"
|
||||
msgid "Personal Inbox"
|
||||
msgstr "Persönlicher Posteingang"
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "Pick a date"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||
msgid "Pick a number"
|
||||
@ -6381,6 +6413,10 @@ msgstr "Empfänger"
|
||||
msgid "Recipient action authentication"
|
||||
msgstr "Empfängeraktion Authentifizierung"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Recipient expiry extended"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Recipient removed email"
|
||||
msgstr "E-Mail des entfernten Empfängers"
|
||||
@ -7095,6 +7131,10 @@ msgstr "Sitzungen wurden widerrufen"
|
||||
msgid "Set a password"
|
||||
msgstr "Ein Passwort festlegen"
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your document properties and recipient information"
|
||||
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
|
||||
@ -7393,6 +7433,10 @@ msgstr "Unterzeichne für"
|
||||
msgid "Signing in..."
|
||||
msgstr "Anmeldung..."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Signing Link Expired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
msgid "Signing Links"
|
||||
@ -8267,6 +8311,10 @@ msgstr "Der Name des Unterzeichners"
|
||||
msgid "The signing link has been copied to your clipboard."
|
||||
msgstr "Der Signierlink wurde in die Zwischenablage kopiert."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||
msgstr "Das Seitenbanner ist eine Nachricht, die oben auf der Seite angezeigt wird. Es kann verwendet werden, um Ihren Nutzern wichtige Informationen anzuzeigen."
|
||||
@ -8585,6 +8633,10 @@ msgstr "Diese Sitzung ist abgelaufen. Bitte versuchen Sie es erneut."
|
||||
msgid "This signer has already signed the document."
|
||||
msgstr "Dieser Unterzeichner hat das Dokument bereits unterschrieben."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "This signing link is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||
msgstr "Dieses Team und alle zugehörigen Daten, ausgenommen Rechnungen, werden permanent gelöscht."
|
||||
|
||||
@ -262,6 +262,11 @@ msgstr "{prefix} created the document"
|
||||
msgid "{prefix} deleted the document"
|
||||
msgstr "{prefix} deleted the document"
|
||||
|
||||
#. placeholder {0}: data.data.recipientEmail
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} extended expiry for {0}"
|
||||
msgstr "{prefix} extended expiry for {0}"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} moved the document to team"
|
||||
msgstr "{prefix} moved the document to team"
|
||||
@ -1700,6 +1705,10 @@ msgctxt "Recipient role progressive verb"
|
||||
msgid "Assisting"
|
||||
msgstr "Assisting"
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "at"
|
||||
msgstr "at"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||
@ -4071,9 +4080,18 @@ msgid "Exceeded timeout"
|
||||
msgstr "Exceeded timeout"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
msgid "Expired"
|
||||
msgstr "Expired"
|
||||
|
||||
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Expired on: {0}"
|
||||
msgstr "Expired on: {0}"
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
@ -4953,6 +4971,11 @@ msgstr "Link expires in 1 hour."
|
||||
msgid "Link expires in 30 minutes."
|
||||
msgstr "Link expires in 30 minutes."
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Link Expiry"
|
||||
msgstr "Link Expiry"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||
msgid "Link template"
|
||||
msgstr "Link template"
|
||||
@ -4974,6 +4997,11 @@ msgstr "Linked At"
|
||||
msgid "Links Generated"
|
||||
msgstr "Links Generated"
|
||||
|
||||
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Links will expire on: {0}"
|
||||
msgstr "Links will expire on: {0}"
|
||||
|
||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Listening to {0}"
|
||||
@ -5962,6 +5990,10 @@ msgstr "Personal Account"
|
||||
msgid "Personal Inbox"
|
||||
msgstr "Personal Inbox"
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "Pick a date"
|
||||
msgstr "Pick a date"
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||
msgid "Pick a number"
|
||||
@ -6376,6 +6408,10 @@ msgstr "Recipient"
|
||||
msgid "Recipient action authentication"
|
||||
msgstr "Recipient action authentication"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Recipient expiry extended"
|
||||
msgstr "Recipient expiry extended"
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Recipient removed email"
|
||||
msgstr "Recipient removed email"
|
||||
@ -7090,6 +7126,10 @@ msgstr "Sessions have been revoked"
|
||||
msgid "Set a password"
|
||||
msgstr "Set a password"
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||
msgstr "Set an expiry duration for signing links (leave empty to disable)"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your document properties and recipient information"
|
||||
msgstr "Set up your document properties and recipient information"
|
||||
@ -7388,6 +7428,10 @@ msgstr "Signing for"
|
||||
msgid "Signing in..."
|
||||
msgstr "Signing in..."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Signing Link Expired"
|
||||
msgstr "Signing Link Expired"
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
msgid "Signing Links"
|
||||
@ -8272,6 +8316,10 @@ msgstr "The signer's name"
|
||||
msgid "The signing link has been copied to your clipboard."
|
||||
msgstr "The signing link has been copied to your clipboard."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||
msgstr "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||
msgstr "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||
@ -8600,6 +8648,10 @@ msgstr "This session has expired. Please try again."
|
||||
msgid "This signer has already signed the document."
|
||||
msgstr "This signer has already signed the document."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "This signing link is no longer valid"
|
||||
msgstr "This signing link is no longer valid"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||
msgstr "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||
|
||||
@ -267,6 +267,11 @@ msgstr "{prefix} creó el documento"
|
||||
msgid "{prefix} deleted the document"
|
||||
msgstr "{prefix} eliminó el documento"
|
||||
|
||||
#. placeholder {0}: data.data.recipientEmail
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} extended expiry for {0}"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} moved the document to team"
|
||||
msgstr "{prefix} movió el documento al equipo"
|
||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
||||
msgid "Assisting"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "at"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
||||
msgstr "Tiempo de espera excedido"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
msgid "Expired"
|
||||
msgstr "Expirado"
|
||||
|
||||
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Expired on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
@ -4958,6 +4976,11 @@ msgstr "El enlace expira en 1 hora."
|
||||
msgid "Link expires in 30 minutes."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Link Expiry"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||
msgid "Link template"
|
||||
msgstr "Enlace de plantilla"
|
||||
@ -4979,6 +5002,11 @@ msgstr ""
|
||||
msgid "Links Generated"
|
||||
msgstr "Enlaces generados"
|
||||
|
||||
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Links will expire on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Listening to {0}"
|
||||
@ -5967,6 +5995,10 @@ msgstr "Cuenta personal"
|
||||
msgid "Personal Inbox"
|
||||
msgstr "Bandeja de entrada personal"
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "Pick a date"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||
msgid "Pick a number"
|
||||
@ -6381,6 +6413,10 @@ msgstr "Destinatario"
|
||||
msgid "Recipient action authentication"
|
||||
msgstr "Autenticación de acción de destinatario"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Recipient expiry extended"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Recipient removed email"
|
||||
msgstr "Correo electrónico de destinatario eliminado"
|
||||
@ -7095,6 +7131,10 @@ msgstr "Las sesiones han sido revocadas"
|
||||
msgid "Set a password"
|
||||
msgstr "Establecer una contraseña"
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your document properties and recipient information"
|
||||
msgstr "Configura las propiedades de tu documento y la información del destinatario"
|
||||
@ -7393,6 +7433,10 @@ msgstr "Firmando para"
|
||||
msgid "Signing in..."
|
||||
msgstr "Iniciando sesión..."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Signing Link Expired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
msgid "Signing Links"
|
||||
@ -8267,6 +8311,10 @@ msgstr "El nombre del firmante"
|
||||
msgid "The signing link has been copied to your clipboard."
|
||||
msgstr "El enlace de firma ha sido copiado a tu portapapeles."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||
msgstr "El banner del sitio es un mensaje que se muestra en la parte superior del sitio. Se puede usar para mostrar información importante a tus usuarios."
|
||||
@ -8587,6 +8635,10 @@ msgstr "Esta sesión ha expirado. Por favor, inténtalo de nuevo."
|
||||
msgid "This signer has already signed the document."
|
||||
msgstr "Este firmante ya ha firmado el documento."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "This signing link is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||
msgstr "Este equipo, y cualquier dato asociado, excluyendo las facturas de facturación, serán eliminados permanentemente."
|
||||
|
||||
@ -267,6 +267,11 @@ msgstr "{prefix} a créé le document"
|
||||
msgid "{prefix} deleted the document"
|
||||
msgstr "{prefix} a supprimé le document"
|
||||
|
||||
#. placeholder {0}: data.data.recipientEmail
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} extended expiry for {0}"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} moved the document to team"
|
||||
msgstr "{prefix} a déplacé le document vers l'équipe"
|
||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
||||
msgid "Assisting"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "at"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
||||
msgstr "Délai dépassé"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
msgid "Expired"
|
||||
msgstr "Expiré"
|
||||
|
||||
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Expired on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
@ -4958,6 +4976,11 @@ msgstr "Le lien expire dans 1 heure."
|
||||
msgid "Link expires in 30 minutes."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Link Expiry"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||
msgid "Link template"
|
||||
msgstr "Modèle de lien"
|
||||
@ -4979,6 +5002,11 @@ msgstr ""
|
||||
msgid "Links Generated"
|
||||
msgstr "Liens générés"
|
||||
|
||||
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Links will expire on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Listening to {0}"
|
||||
@ -5967,6 +5995,10 @@ msgstr "Compte personnel"
|
||||
msgid "Personal Inbox"
|
||||
msgstr "Boîte de réception personnelle"
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "Pick a date"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||
msgid "Pick a number"
|
||||
@ -6381,6 +6413,10 @@ msgstr "Destinataire"
|
||||
msgid "Recipient action authentication"
|
||||
msgstr "Authentification d'action de destinataire"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Recipient expiry extended"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Recipient removed email"
|
||||
msgstr "E-mail de destinataire supprimé"
|
||||
@ -7095,6 +7131,10 @@ msgstr "Les sessions ont été révoquées"
|
||||
msgid "Set a password"
|
||||
msgstr "Définir un mot de passe"
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your document properties and recipient information"
|
||||
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
|
||||
@ -7393,6 +7433,10 @@ msgstr "Signé pour"
|
||||
msgid "Signing in..."
|
||||
msgstr "Connexion en cours..."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Signing Link Expired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
msgid "Signing Links"
|
||||
@ -8267,6 +8311,10 @@ msgstr "Le nom du signataire"
|
||||
msgid "The signing link has been copied to your clipboard."
|
||||
msgstr "Le lien de signature a été copié dans votre presse-papiers."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||
msgstr "La bannière du site est un message affiché en haut du site. Elle peut être utilisée pour afficher des informations importantes à vos utilisateurs."
|
||||
@ -8585,6 +8633,10 @@ msgstr "Cette session a expiré. Veuillez réessayer."
|
||||
msgid "This signer has already signed the document."
|
||||
msgstr "Ce signataire a déjà signé le document."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "This signing link is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||
msgstr "Cette équipe, et toutes les données associées à l'exception des factures de facturation, seront définitivement supprimées."
|
||||
|
||||
@ -267,6 +267,11 @@ msgstr "{prefix} ha creato il documento"
|
||||
msgid "{prefix} deleted the document"
|
||||
msgstr "{prefix} ha eliminato il documento"
|
||||
|
||||
#. placeholder {0}: data.data.recipientEmail
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} extended expiry for {0}"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} moved the document to team"
|
||||
msgstr "{prefix} ha spostato il documento al team"
|
||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
||||
msgid "Assisting"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "at"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
||||
msgstr "Tempo scaduto"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
msgid "Expired"
|
||||
msgstr "Scaduto"
|
||||
|
||||
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Expired on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
@ -4958,6 +4976,11 @@ msgstr "Il link scade tra 1 ora."
|
||||
msgid "Link expires in 30 minutes."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Link Expiry"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||
msgid "Link template"
|
||||
msgstr "Collega modello"
|
||||
@ -4979,6 +5002,11 @@ msgstr ""
|
||||
msgid "Links Generated"
|
||||
msgstr "Link Generati"
|
||||
|
||||
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Links will expire on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Listening to {0}"
|
||||
@ -5967,6 +5995,10 @@ msgstr "Account personale"
|
||||
msgid "Personal Inbox"
|
||||
msgstr "Posta in arrivo personale"
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "Pick a date"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||
msgid "Pick a number"
|
||||
@ -6381,6 +6413,10 @@ msgstr "Destinatario"
|
||||
msgid "Recipient action authentication"
|
||||
msgstr "Autenticazione azione destinatario"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Recipient expiry extended"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Recipient removed email"
|
||||
msgstr "Email destinatario rimosso"
|
||||
@ -7095,6 +7131,10 @@ msgstr "Le sessioni sono state revocate"
|
||||
msgid "Set a password"
|
||||
msgstr "Imposta una password"
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your document properties and recipient information"
|
||||
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
|
||||
@ -7393,6 +7433,10 @@ msgstr "Firma per"
|
||||
msgid "Signing in..."
|
||||
msgstr "Accesso in corso..."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Signing Link Expired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
msgid "Signing Links"
|
||||
@ -8275,6 +8319,10 @@ msgstr "Il nome del firmatario"
|
||||
msgid "The signing link has been copied to your clipboard."
|
||||
msgstr "Il link di firma è stato copiato negli appunti."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||
msgstr "Il banner del sito è un messaggio che viene mostrato in cima al sito. Può essere utilizzato per visualizzare informazioni importanti ai tuoi utenti."
|
||||
@ -8601,6 +8649,10 @@ msgstr "Questa sessione è scaduta. Per favore prova di nuovo."
|
||||
msgid "This signer has already signed the document."
|
||||
msgstr "Questo firmatario ha già firmato il documento."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "This signing link is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||
msgstr "Questo team e tutti i dati associati, escluse le fatture di fatturazione, verranno eliminati definitivamente."
|
||||
|
||||
@ -267,6 +267,11 @@ msgstr "Użytkownik {prefix} utworzył dokument"
|
||||
msgid "{prefix} deleted the document"
|
||||
msgstr "Użytkownik {prefix} usunął dokument"
|
||||
|
||||
#. placeholder {0}: data.data.recipientEmail
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} extended expiry for {0}"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "{prefix} moved the document to team"
|
||||
msgstr "Użytkownik {prefix} przeniósł dokument do zespołu"
|
||||
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
|
||||
msgid "Assisting"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "at"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
|
||||
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
|
||||
msgstr "Przekroczono limit czasu"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
#: apps/remix/app/components/tables/inbox-table.tsx
|
||||
#: apps/remix/app/components/tables/documents-table-action-button.tsx
|
||||
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
|
||||
msgid "Expired"
|
||||
msgstr "Wygasł"
|
||||
|
||||
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Expired on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
|
||||
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
|
||||
msgid "Expires in {0}"
|
||||
@ -4958,6 +4976,11 @@ msgstr "Link wygaśnie za 1 godzinę."
|
||||
msgid "Link expires in 30 minutes."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
#: packages/ui/primitives/document-flow/add-settings.tsx
|
||||
msgid "Link Expiry"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
|
||||
msgid "Link template"
|
||||
msgstr "Szablon linku"
|
||||
@ -4979,6 +5002,11 @@ msgstr ""
|
||||
msgid "Links Generated"
|
||||
msgstr "Wygenerowane linki"
|
||||
|
||||
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Links will expire on: {0}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Listening to {0}"
|
||||
@ -5967,6 +5995,10 @@ msgstr "Konto osobiste"
|
||||
msgid "Personal Inbox"
|
||||
msgstr "Skrzynka odbiorcza osobista"
|
||||
|
||||
#: packages/ui/primitives/date-time-picker.tsx
|
||||
msgid "Pick a date"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
|
||||
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
|
||||
msgid "Pick a number"
|
||||
@ -6381,6 +6413,10 @@ msgstr "Odbiorca"
|
||||
msgid "Recipient action authentication"
|
||||
msgstr "Uwierzytelnianie odbiorcy"
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
msgid "Recipient expiry extended"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Recipient removed email"
|
||||
msgstr "Wiadomość o usuniętym odbiorcy"
|
||||
@ -7095,6 +7131,10 @@ msgstr "Sesje zostały odwołane"
|
||||
msgid "Set a password"
|
||||
msgstr "Ustaw hasło"
|
||||
|
||||
#: packages/ui/primitives/expiry-settings-picker.tsx
|
||||
msgid "Set an expiry duration for signing links (leave empty to disable)"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
|
||||
msgid "Set up your document properties and recipient information"
|
||||
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
|
||||
@ -7393,6 +7433,10 @@ msgstr "Podpis w imieniu"
|
||||
msgid "Signing in..."
|
||||
msgstr "Logowanie..."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "Signing Link Expired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
|
||||
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
|
||||
msgid "Signing Links"
|
||||
@ -8267,6 +8311,10 @@ msgstr "Nazwa podpisującego"
|
||||
msgid "The signing link has been copied to your clipboard."
|
||||
msgstr "Link do podpisu został skopiowany do schowka."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
|
||||
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
|
||||
msgstr "Baner strony to wiadomość, która jest wyświetlana u góry strony. Może być używany do wyświetlania ważnych informacji użytkownikom."
|
||||
@ -8585,6 +8633,10 @@ msgstr "Ta sesja wygasła. Proszę spróbować ponownie."
|
||||
msgid "This signer has already signed the document."
|
||||
msgstr "Ten sygnatariusz już podpisał dokument."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
|
||||
msgid "This signing link is no longer valid"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
|
||||
msgstr "Ten zespół oraz wszelkie powiązane dane, z wyjątkiem faktur, zostaną trwale usunięte."
|
||||
|
||||
@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||
'DOCUMENT_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend.
|
||||
|
||||
// ACCESS AUTH 2FA events.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||
@ -639,6 +640,20 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient expiry extended.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventRecipientExpiryExtendedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED),
|
||||
data: z.object({
|
||||
recipientId: z.number(),
|
||||
recipientName: z.string().optional(),
|
||||
recipientEmail: z.string(),
|
||||
previousExpiryDate: z.date().nullable(),
|
||||
newExpiryDate: z.date().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@ -680,6 +695,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventRecipientExpiryExtendedSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@ -107,6 +107,16 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
||||
|
||||
export const ZDocumentExpiryAmountSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.describe('The amount for expiry duration (e.g., 3 for "3 days").');
|
||||
|
||||
export const ZDocumentExpiryUnitSchema = z
|
||||
.enum(['minutes', 'hours', 'days', 'weeks', 'months'])
|
||||
.describe('The unit for expiry duration (e.g., "days" for "3 days").');
|
||||
|
||||
/**
|
||||
* Note: Any updates to this will cause public API changes. You will need to update
|
||||
* all corresponding areas where this is used (some places that use this needs to pass
|
||||
@ -128,6 +138,8 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: z.string().email().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||
});
|
||||
|
||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||
|
||||
@ -69,6 +69,8 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
expiryAmount: true,
|
||||
expiryUnit: true,
|
||||
}).extend({
|
||||
password: z.string().nullable().default(null),
|
||||
documentId: z.number().default(-1).optional(),
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
|
||||
|
||||
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;
|
||||
@ -515,6 +515,10 @@ export const formatDocumentAuditLogAction = (
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED }, (data) => ({
|
||||
anonymous: msg`Recipient expiry extended`,
|
||||
identified: msg`${prefix} extended expiry for ${data.data.recipientEmail}`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return {
|
||||
|
||||
@ -20,6 +20,26 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
|
||||
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||
};
|
||||
|
||||
const getExpiryAmount = (meta: Partial<DocumentMeta> | undefined | null): number | null => {
|
||||
if (!meta) return null;
|
||||
|
||||
if ('expiryAmount' in meta && meta.expiryAmount !== undefined) {
|
||||
return meta.expiryAmount;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getExpiryUnit = (meta: Partial<DocumentMeta> | undefined | null): string | null => {
|
||||
if (!meta) return null;
|
||||
|
||||
if ('expiryUnit' in meta && meta.expiryUnit !== undefined) {
|
||||
return meta.expiryUnit;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the derived document meta which should be used when creating a document
|
||||
* from scratch, or from a template.
|
||||
@ -62,6 +82,10 @@ export const extractDerivedDocumentMeta = (
|
||||
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
||||
emailSettings:
|
||||
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
|
||||
// Expiry settings.
|
||||
expiryAmount: getExpiryAmount(meta),
|
||||
expiryUnit: getExpiryUnit(meta),
|
||||
} satisfies Omit<DocumentMeta, 'id'>;
|
||||
};
|
||||
|
||||
|
||||
72
packages/lib/utils/expiry.ts
Normal file
72
packages/lib/utils/expiry.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export interface DurationValue {
|
||||
amount: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export const calculateRecipientExpiry = (
|
||||
documentExpiryAmount?: number | null,
|
||||
documentExpiryUnit?: string | null,
|
||||
fromDate: Date = new Date(),
|
||||
): Date | null => {
|
||||
if (!documentExpiryAmount || !documentExpiryUnit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (documentExpiryUnit) {
|
||||
case 'minutes':
|
||||
return DateTime.fromJSDate(fromDate).plus({ minutes: documentExpiryAmount }).toJSDate();
|
||||
case 'hours':
|
||||
return DateTime.fromJSDate(fromDate).plus({ hours: documentExpiryAmount }).toJSDate();
|
||||
case 'days':
|
||||
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
|
||||
case 'weeks':
|
||||
return DateTime.fromJSDate(fromDate).plus({ weeks: documentExpiryAmount }).toJSDate();
|
||||
case 'months':
|
||||
return DateTime.fromJSDate(fromDate).plus({ months: documentExpiryAmount }).toJSDate();
|
||||
default:
|
||||
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
|
||||
}
|
||||
};
|
||||
|
||||
export const isRecipientExpired = (recipient: Recipient): boolean => {
|
||||
if (!recipient.expired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTime.now() > DateTime.fromJSDate(recipient.expired);
|
||||
};
|
||||
|
||||
export const isValidExpirySettings = (
|
||||
expiryAmount?: number | null,
|
||||
expiryUnit?: string | null,
|
||||
): boolean => {
|
||||
if (!expiryAmount || !expiryUnit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiryAmount > 0 && ['minutes', 'hours', 'days', 'weeks', 'months'].includes(expiryUnit);
|
||||
};
|
||||
|
||||
export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => {
|
||||
switch (duration.unit) {
|
||||
case 'minutes':
|
||||
return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate();
|
||||
case 'hours':
|
||||
return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate();
|
||||
case 'days':
|
||||
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||
case 'weeks':
|
||||
return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate();
|
||||
case 'months':
|
||||
return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate();
|
||||
default:
|
||||
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||
}
|
||||
};
|
||||
|
||||
export const formatExpiryDate = (date: Date): string => {
|
||||
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm');
|
||||
};
|
||||
@ -1,37 +0,0 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
export const isValidReturnTo = (returnTo?: string) => {
|
||||
if (!returnTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode if it's URL encoded
|
||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeReturnTo = (returnTo?: string) => {
|
||||
if (!returnTo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode if it's URL encoded
|
||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
|
||||
ADD COLUMN "expiryUnit" TEXT;
|
||||
@ -1,15 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "EnvelopeAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"data" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"envelopeId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "EnvelopeAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EnvelopeAttachment" ADD CONSTRAINT "EnvelopeAttachment_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -422,8 +422,6 @@ model Envelope {
|
||||
|
||||
documentMetaId String @unique
|
||||
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
|
||||
|
||||
envelopeAttachments EnvelopeAttachment[]
|
||||
}
|
||||
|
||||
model EnvelopeItem {
|
||||
@ -507,25 +505,12 @@ model DocumentMeta {
|
||||
emailReplyTo String?
|
||||
emailId String?
|
||||
|
||||
expiryAmount Int?
|
||||
expiryUnit String?
|
||||
|
||||
envelope Envelope?
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';"])
|
||||
model EnvelopeAttachment {
|
||||
id String @id @default(cuid())
|
||||
|
||||
type String /// [EnvelopeAttachmentType] @zod.custom.use(ZEnvelopeAttachmentTypeSchema)
|
||||
label String
|
||||
|
||||
data String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
envelopeId String
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ReadStatus {
|
||||
NOT_OPENED
|
||||
OPENED
|
||||
@ -540,6 +525,7 @@ enum SigningStatus {
|
||||
NOT_SIGNED
|
||||
SIGNED
|
||||
REJECTED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum RecipientRole {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user