mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
11 Commits
chore/bloc
...
v1.13.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b5c50ed88 | |||
| e4e9e749e5 | |||
| 37ae6a86fd | |||
| 88836404d1 | |||
| 2eebc0e439 | |||
| 4a3859ec60 | |||
| 49b792503f | |||
| c3dc76b1b4 | |||
| daab8461c7 | |||
| 1ffc4bd703 | |||
| f15c0778b5 |
@ -13,6 +13,10 @@ 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,3 +27,33 @@ 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.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
|
||||
@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
const onFormSubmit = async () => {
|
||||
try {
|
||||
await deleteFolder({
|
||||
id: folder.id,
|
||||
folderId: folder.id,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||
@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||
if (!folder) return;
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await moveFolder({
|
||||
id: folder.id,
|
||||
parentId: targetFolderId || null,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
parentId: targetFolderId || null,
|
||||
},
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@ -61,8 +61,6 @@ 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: {
|
||||
@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
|
||||
try {
|
||||
await updateFolder({
|
||||
id: folder.id,
|
||||
name: data.name,
|
||||
visibility: isTeamContext
|
||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||
: DocumentVisibility.EVERYONE,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<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,6 +185,10 @@ 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.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId ?? null,
|
||||
type: FolderType.TEMPLATE,
|
||||
|
||||
@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
|
||||
email?: string;
|
||||
returnTo: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
};
|
||||
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
|
||||
email,
|
||||
returnTo,
|
||||
// isGoogleSSOEnabled,
|
||||
// isMicrosoftSSOEnabled,
|
||||
// isOIDCSSOEnabled,
|
||||
// oidcProviderLabel,
|
||||
}: EmbedAuthenticationRequiredProps) => {
|
||||
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
|
||||
<SignInForm
|
||||
// Embed currently not supported.
|
||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
// oidcProviderLabel={oidcProviderLabel}
|
||||
className="mt-4"
|
||||
|
||||
@ -37,6 +37,7 @@ 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';
|
||||
@ -44,6 +45,7 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
||||
|
||||
export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
@ -55,9 +57,10 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
envelopeId,
|
||||
updatedAt,
|
||||
documentData,
|
||||
recipient: _recipient,
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
@ -321,9 +324,13 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<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={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,6 +37,7 @@ 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';
|
||||
@ -48,6 +49,7 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
documentData: DocumentData;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
@ -62,6 +64,7 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
token,
|
||||
documentId,
|
||||
envelopeId,
|
||||
documentData,
|
||||
recipient,
|
||||
fields,
|
||||
@ -274,15 +277,17 @@ 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 />}
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<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,6 +70,7 @@ export type SignInFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
returnTo?: string;
|
||||
@ -79,6 +80,7 @@ export const SignInForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
@ -90,11 +92,14 @@ 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(() => {
|
||||
@ -271,6 +276,22 @@ 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({
|
||||
@ -297,6 +318,8 @@ export const SignInForm = ({
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
|
||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
@ -363,42 +386,64 @@ export const SignInForm = ({
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
|
||||
{(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>
|
||||
)}
|
||||
{!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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
|
||||
@ -66,14 +66,18 @@ 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();
|
||||
@ -84,6 +88,8 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
@ -106,7 +112,7 @@ export const SignUpForm = ({
|
||||
signature,
|
||||
});
|
||||
|
||||
await navigate(`/unverified-account`);
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
|
||||
toast({
|
||||
title: _(msg`Registration Successful`),
|
||||
@ -148,6 +154,20 @@ 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();
|
||||
@ -227,7 +247,7 @@ export const SignUpForm = ({
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
@ -302,7 +322,7 @@ export const SignUpForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||
{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" />
|
||||
@ -330,6 +350,26 @@ 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
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
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 } = useRequiredDocumentSigningAuthContext();
|
||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath: `/signin#email=${email}`,
|
||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
|
||||
<AlertDescription>
|
||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||
<span>
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{/* Todo: Translate */}
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||
in as <strong>{recipient.email}</strong>
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
||||
@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
}: DocumentSigningAuthDialogProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
|
||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||
const validAuthTypes = availableAuthTypes.filter(
|
||||
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
|
||||
match({ documentAuthType: selectedAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||
{
|
||||
user: P.when(
|
||||
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
||||
),
|
||||
}, // Assume all current auth methods requires them to be logged in.
|
||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
|
||||
@ -37,6 +37,7 @@ export type DocumentSigningAuthContextValue = {
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||
isAuthRedirectRequired: boolean;
|
||||
isDirectTemplate?: boolean;
|
||||
isCurrentlyAuthenticating: boolean;
|
||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||
passkeyData: PasskeyData;
|
||||
@ -65,6 +66,7 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
isDirectTemplate?: boolean;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@ -72,6 +74,7 @@ export interface DocumentSigningAuthProviderProps {
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
recipient: initialRecipient,
|
||||
isDirectTemplate = false,
|
||||
user,
|
||||
children,
|
||||
}: DocumentSigningAuthProviderProps) => {
|
||||
@ -201,6 +204,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
derivedRecipientAccessAuth,
|
||||
derivedRecipientActionAuth,
|
||||
isAuthRedirectRequired,
|
||||
isDirectTemplate,
|
||||
isCurrentlyAuthenticating,
|
||||
setIsCurrentlyAuthenticating,
|
||||
passkeyData,
|
||||
|
||||
@ -32,6 +32,7 @@ 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';
|
||||
@ -231,7 +232,13 @@ export const DocumentSigningPageViewV1 = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={document.envelopeId}
|
||||
token={recipient.token}
|
||||
/>
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
</div>
|
||||
</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,6 +19,7 @@ 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';
|
||||
@ -31,8 +32,13 @@ const EnvelopeSignerPageRenderer = lazy(
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
const {
|
||||
envelope,
|
||||
recipient,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50">
|
||||
@ -83,6 +89,10 @@ 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" />
|
||||
|
||||
@ -0,0 +1,241 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -22,6 +22,7 @@ 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';
|
||||
|
||||
@ -131,6 +132,8 @@ 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,6 +12,7 @@ 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';
|
||||
@ -28,22 +29,15 @@ 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,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const formatPath = () => {
|
||||
const rootPath =
|
||||
folder.type === FolderType.DOCUMENT
|
||||
@ -53,6 +47,15 @@ export const FolderCard = ({
|
||||
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">
|
||||
@ -112,9 +115,7 @@ export const FolderCard = ({
|
||||
<Trans>Move</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
|
||||
>
|
||||
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
|
||||
<PinIcon className="mr-2 h-4 w-4" />
|
||||
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -34,9 +34,6 @@ 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,
|
||||
@ -155,8 +152,6 @@ 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);
|
||||
@ -180,8 +175,6 @@ 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);
|
||||
|
||||
@ -9,6 +9,7 @@ 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';
|
||||
@ -122,11 +123,13 @@ export default function DocumentEditPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{document.useLegacyFieldInsertion && (
|
||||
<div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentAttachmentsPopover envelopeId={document.envelopeId} />
|
||||
|
||||
{document.useLegacyFieldInsertion && (
|
||||
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentEditForm
|
||||
|
||||
@ -42,9 +42,6 @@ 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);
|
||||
|
||||
@ -113,8 +110,6 @@ export default function DocumentsFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@ -147,8 +142,6 @@ 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,6 +8,7 @@ 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';
|
||||
@ -87,6 +88,8 @@ 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,9 +42,6 @@ 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);
|
||||
|
||||
@ -113,8 +110,6 @@ export default function TemplatesFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@ -147,8 +142,6 @@ 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,6 +95,7 @@ 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">
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
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';
|
||||
@ -23,22 +27,45 @@ 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('/');
|
||||
throw redirect(returnTo || '/');
|
||||
}
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
|
||||
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');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
@ -54,15 +81,20 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<SignInForm
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
oidcProviderLabel={oidcProviderLabel}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
|
||||
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||
{!isEmbeddedRedirect && 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="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
<Link
|
||||
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
|
||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Trans>
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_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';
|
||||
@ -12,31 +17,40 @@ export function meta() {
|
||||
return appMetaTags('Sign Up');
|
||||
}
|
||||
|
||||
export function loader() {
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
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, isOIDCSSOEnabled } = loaderData;
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = 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,6 +2,7 @@ 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';
|
||||
@ -29,11 +30,13 @@ 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,
|
||||
};
|
||||
@ -44,7 +47,8 @@ export default function Layout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||
loaderData || {};
|
||||
|
||||
const error = useRouteError();
|
||||
|
||||
@ -53,6 +57,7 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<EmbedAuthenticationRequired
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
oidcProviderLabel={oidcProviderLabel}
|
||||
email={error.data.email}
|
||||
|
||||
@ -69,7 +69,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
@ -117,11 +116,13 @@ 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,6 +164,7 @@ export default function EmbedSignDocumentPage() {
|
||||
<EmbedSignDocumentClientPage
|
||||
token={token}
|
||||
documentId={document.id}
|
||||
envelopeId={document.envelopeId}
|
||||
documentData={document.documentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
|
||||
@ -4,7 +4,6 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||
|
||||
export const ZBaseEmbedAuthoringSchema = z
|
||||
.object({
|
||||
token: z.string(),
|
||||
externalId: z.string().optional(),
|
||||
features: z
|
||||
.object({
|
||||
|
||||
@ -103,5 +103,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.12.10"
|
||||
"version": "1.13.2"
|
||||
}
|
||||
|
||||
1
apps/remix/public/static/microsoft.svg
Normal file
1
apps/remix/public/static/microsoft.svg
Normal file
@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 356 B |
@ -1,6 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from 'hono/cors';
|
||||
import { requestId } from 'hono/request-id';
|
||||
import type { RequestIdVariables } from 'hono/request-id';
|
||||
import type { Logger } from 'pino';
|
||||
@ -83,12 +84,14 @@ app.route('/api/auth', auth);
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||
|
||||
// Unstable API server routes. Order matters for these two.
|
||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
||||
|
||||
export default app;
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.2",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -89,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.2",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.3.2",
|
||||
"@documenso/api": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.12.10",
|
||||
"version": "1.13.2",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@ -427,6 +427,7 @@ 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,
|
||||
@ -497,6 +498,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
publicDescription,
|
||||
type,
|
||||
meta,
|
||||
attachments,
|
||||
} = body;
|
||||
|
||||
try {
|
||||
@ -568,6 +570,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
publicDescription,
|
||||
},
|
||||
meta,
|
||||
attachments,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
@ -792,6 +795,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
...body.meta,
|
||||
title: body.title,
|
||||
},
|
||||
attachments: body.attachments,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ 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);
|
||||
@ -197,6 +198,15 @@ 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>;
|
||||
@ -262,6 +272,15 @@ 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,6 +24,7 @@ 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';
|
||||
|
||||
@ -326,11 +327,6 @@ 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);
|
||||
});
|
||||
@ -407,11 +403,6 @@ 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);
|
||||
});
|
||||
@ -2715,4 +2706,154 @@ 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,6 +222,22 @@ 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,6 +26,16 @@ 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,6 +5,7 @@ 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';
|
||||
@ -177,6 +178,12 @@ 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, OidcAuthOptions } from '../config';
|
||||
import { GoogleAuthOptions, MicrosoftAuthOptions, 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,4 +45,11 @@ export const callbackRoute = new Hono<HonoAuthContext>()
|
||||
/**
|
||||
* Google callback verification.
|
||||
*/
|
||||
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }));
|
||||
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
|
||||
|
||||
/**
|
||||
* Microsoft callback verification.
|
||||
*/
|
||||
.get('/microsoft', async (c) =>
|
||||
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
|
||||
);
|
||||
|
||||
@ -2,7 +2,7 @@ import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { GoogleAuthOptions, MicrosoftAuthOptions, 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,6 +24,20 @@ 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.
|
||||
*/
|
||||
|
||||
@ -6,6 +6,7 @@ export const SALT_ROUNDS = 12;
|
||||
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||
DOCUMENSO: 'Documenso',
|
||||
GOOGLE: 'Google',
|
||||
MICROSOFT: 'Microsoft',
|
||||
OIDC: 'OIDC',
|
||||
};
|
||||
|
||||
@ -13,6 +14,10 @@ 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') &&
|
||||
|
||||
@ -81,11 +81,15 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
||||
const { user: owner } = envelope;
|
||||
|
||||
const completedDocumentEmailAttachments = await Promise.all(
|
||||
envelope.envelopeItems.map(async (document) => {
|
||||
const file = await getFileServerSide(document.documentData);
|
||||
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';
|
||||
|
||||
return {
|
||||
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
|
||||
content: Buffer.from(file),
|
||||
contentType: 'application/pdf',
|
||||
};
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
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,6 +20,7 @@ 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,
|
||||
@ -58,6 +59,11 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||
folderId?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: TEnvelopeAttachmentType;
|
||||
}>;
|
||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -67,6 +73,7 @@ export const createEnvelope = async ({
|
||||
teamId,
|
||||
normalizePdf,
|
||||
data,
|
||||
attachments,
|
||||
meta,
|
||||
requestMetadata,
|
||||
internalVersion,
|
||||
@ -246,6 +253,15 @@ export const createEnvelope = async ({
|
||||
})),
|
||||
},
|
||||
},
|
||||
envelopeAttachments: {
|
||||
createMany: {
|
||||
data: (attachments || []).map((attachment) => ({
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
type: attachment.type ?? 'link',
|
||||
})),
|
||||
},
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
authOptions,
|
||||
@ -338,6 +354,7 @@ export const createEnvelope = async ({
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeItems: true,
|
||||
envelopeAttachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
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 {
|
||||
@ -22,6 +24,27 @@ 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,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@ -20,6 +21,9 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -39,7 +43,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
||||
|
||||
return await prisma.folder.delete({
|
||||
where: {
|
||||
id: folderId,
|
||||
id: folder.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
117
packages/lib/server-only/folder/find-folders-internal.ts
Normal file
117
packages/lib/server-only/folder/find-folders-internal.ts
Normal file
@ -0,0 +1,117 @@
|
||||
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,9 +1,11 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type { Prisma } 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 {
|
||||
@ -11,102 +13,48 @@ export interface FindFoldersOptions {
|
||||
teamId: number;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
|
||||
export const findFolders = async ({
|
||||
userId,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: FindFoldersOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = {
|
||||
const whereClause: Prisma.FolderWhereInput = {
|
||||
parentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
{
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
|
||||
@ -1,51 +1,30 @@
|
||||
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 { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
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 visibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
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,
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -1,40 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,40 +0,0 @@
|
||||
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 { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
import type { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface UpdateFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
folderId: string;
|
||||
name: string;
|
||||
visibility: DocumentVisibility;
|
||||
type?: TFolderType;
|
||||
data: {
|
||||
parentId?: string | null;
|
||||
name?: string;
|
||||
visibility?: DocumentVisibility;
|
||||
pinned?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const updateFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
name,
|
||||
visibility,
|
||||
type,
|
||||
}: UpdateFolderOptions) => {
|
||||
export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
|
||||
const { parentId, name, visibility, pinned } = data;
|
||||
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
@ -30,7 +30,9 @@ export const updateFolder = async ({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -40,17 +42,66 @@ export const updateFolder = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
|
||||
const effectiveVisibility =
|
||||
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
visibility: effectiveVisibility,
|
||||
visibility,
|
||||
parentId,
|
||||
pinned,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -640,6 +640,23 @@ 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,
|
||||
|
||||
@ -91,6 +91,12 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
envelopeItemId?: string;
|
||||
}[];
|
||||
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: 'link';
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Values that will override the predefined values in the template.
|
||||
*/
|
||||
@ -295,6 +301,7 @@ export const createDocumentFromTemplate = async ({
|
||||
requestMetadata,
|
||||
folderId,
|
||||
prefillFields,
|
||||
attachments,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
@ -667,6 +674,33 @@ 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,22 +1,23 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async () => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Document')
|
||||
.selectFrom('Envelope')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.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'), 'Document.updatedAt']) as any))
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@ -127,7 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: z.string().email().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
});
|
||||
|
||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||
|
||||
5
packages/lib/types/envelope-attachment.ts
Normal file
5
packages/lib/types/envelope-attachment.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
|
||||
|
||||
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;
|
||||
37
packages/lib/utils/is-valid-return-to.ts
Normal file
37
packages/lib/utils/is-valid-return-to.ts
Normal file
@ -0,0 +1,37 @@
|
||||
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,15 @@
|
||||
-- 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,6 +422,8 @@ model Envelope {
|
||||
|
||||
documentMetaId String @unique
|
||||
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
|
||||
|
||||
envelopeAttachments EnvelopeAttachment[]
|
||||
}
|
||||
|
||||
model EnvelopeItem {
|
||||
@ -508,6 +510,22 @@ model DocumentMeta {
|
||||
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
|
||||
|
||||
3
packages/prisma/types/types.d.ts
vendored
3
packages/prisma/types/types.d.ts
vendored
@ -5,6 +5,7 @@ import type {
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
||||
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
|
||||
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { TClaimFlags } from '@documenso/lib/types/subscription';
|
||||
|
||||
@ -23,6 +24,8 @@ declare global {
|
||||
type RecipientAuthOptions = TRecipientAuthOptions;
|
||||
|
||||
type FieldMeta = TFieldMetaNotOptionalSchema;
|
||||
|
||||
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { createAttachment } from '@documenso/lib/server-only/envelope-attachment/create-attachment';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZCreateAttachmentRequestSchema,
|
||||
ZCreateAttachmentResponseSchema,
|
||||
} from './create-attachment.types';
|
||||
|
||||
export const createAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/document/attachment/create',
|
||||
summary: 'Create attachment',
|
||||
description: 'Create a new attachment for a document',
|
||||
tags: ['Document'],
|
||||
},
|
||||
})
|
||||
.input(ZCreateAttachmentRequestSchema)
|
||||
.output(ZCreateAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const { documentId, data } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { documentId, label: data.label },
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
});
|
||||
|
||||
await createAttachment({
|
||||
envelopeId: envelope.id,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateAttachmentRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
data: z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZCreateAttachmentResponseSchema = z.void();
|
||||
|
||||
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
|
||||
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;
|
||||
@ -0,0 +1,36 @@
|
||||
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZDeleteAttachmentRequestSchema,
|
||||
ZDeleteAttachmentResponseSchema,
|
||||
} from './delete-attachment.types';
|
||||
|
||||
export const deleteAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/document/attachment/delete',
|
||||
summary: 'Delete attachment',
|
||||
description: 'Delete an attachment from a document',
|
||||
tags: ['Document'],
|
||||
},
|
||||
})
|
||||
.input(ZDeleteAttachmentRequestSchema)
|
||||
.output(ZDeleteAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const { id } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { id },
|
||||
});
|
||||
|
||||
await deleteAttachment({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeleteAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteAttachmentResponseSchema = z.void();
|
||||
|
||||
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
|
||||
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;
|
||||
@ -0,0 +1,52 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZFindAttachmentsRequestSchema,
|
||||
ZFindAttachmentsResponseSchema,
|
||||
} from './find-attachments.types';
|
||||
|
||||
export const findAttachmentsRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/document/attachment',
|
||||
summary: 'Find attachments',
|
||||
description: 'Find all attachments for a document',
|
||||
tags: ['Document'],
|
||||
},
|
||||
})
|
||||
.input(ZFindAttachmentsRequestSchema)
|
||||
.output(ZFindAttachmentsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { documentId } = input;
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { documentId },
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
});
|
||||
|
||||
const data = await findAttachmentsByEnvelopeId({
|
||||
envelopeId: envelope.id,
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
|
||||
export const ZFindAttachmentsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export const ZFindAttachmentsResponseSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: ZEnvelopeAttachmentTypeSchema,
|
||||
label: z.string(),
|
||||
data: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TFindAttachmentsRequest = z.infer<typeof ZFindAttachmentsRequestSchema>;
|
||||
export type TFindAttachmentsResponse = z.infer<typeof ZFindAttachmentsResponseSchema>;
|
||||
@ -0,0 +1,37 @@
|
||||
import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment/update-attachment';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZUpdateAttachmentRequestSchema,
|
||||
ZUpdateAttachmentResponseSchema,
|
||||
} from './update-attachment.types';
|
||||
|
||||
export const updateAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/document/attachment/update',
|
||||
summary: 'Update attachment',
|
||||
description: 'Update an existing attachment',
|
||||
tags: ['Document'],
|
||||
},
|
||||
})
|
||||
.input(ZUpdateAttachmentRequestSchema)
|
||||
.output(ZUpdateAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const { id, data } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { id },
|
||||
});
|
||||
|
||||
await updateAttachment({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
data,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZUpdateAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateAttachmentResponseSchema = z.void();
|
||||
|
||||
export type TUpdateAttachmentRequest = z.infer<typeof ZUpdateAttachmentRequestSchema>;
|
||||
export type TUpdateAttachmentResponse = z.infer<typeof ZUpdateAttachmentResponseSchema>;
|
||||
@ -37,6 +37,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
||||
recipients,
|
||||
meta,
|
||||
folderId,
|
||||
attachments,
|
||||
} = input;
|
||||
|
||||
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
||||
@ -86,7 +87,11 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
||||
},
|
||||
],
|
||||
},
|
||||
meta,
|
||||
attachments,
|
||||
meta: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings ?? undefined,
|
||||
},
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
|
||||
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
import {
|
||||
ZFieldHeightSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
@ -68,6 +69,15 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
|
||||
}),
|
||||
)
|
||||
|
||||
.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(),
|
||||
meta: ZDocumentMetaCreateSchema.optional(),
|
||||
});
|
||||
|
||||
@ -16,7 +16,7 @@ export const createDocumentRoute = authenticatedProcedure
|
||||
.output(ZCreateDocumentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
const { title, documentDataId, timezone, folderId } = input;
|
||||
const { title, documentDataId, timezone, folderId, attachments } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@ -48,6 +48,7 @@ export const createDocumentRoute = authenticatedProcedure
|
||||
},
|
||||
],
|
||||
},
|
||||
attachments,
|
||||
normalizePdf: true,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
|
||||
import { ZDocumentTitleSchema } from './schema';
|
||||
|
||||
@ -19,6 +20,15 @@ export const ZCreateDocumentRequestSchema = z.object({
|
||||
documentDataId: z.string().min(1),
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
folderId: z.string().describe('The ID of the folder to create the document in').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 const ZCreateDocumentResponseSchema = z.object({
|
||||
|
||||
@ -37,7 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
emailSettings: meta.emailSettings,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
emailReplyTo: meta.emailReplyTo,
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { router } from '../trpc';
|
||||
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
|
||||
import { createAttachmentRoute } from './attachment/create-attachment';
|
||||
import { deleteAttachmentRoute } from './attachment/delete-attachment';
|
||||
import { findAttachmentsRoute } from './attachment/find-attachments';
|
||||
import { updateAttachmentRoute } from './attachment/update-attachment';
|
||||
import { createDocumentRoute } from './create-document';
|
||||
import { createDocumentTemporaryRoute } from './create-document-temporary';
|
||||
import { deleteDocumentRoute } from './delete-document';
|
||||
@ -53,4 +57,10 @@ export const documentRouter = router({
|
||||
find: findInboxRoute,
|
||||
getCount: getInboxCountRoute,
|
||||
}),
|
||||
attachment: {
|
||||
create: createAttachmentRoute,
|
||||
update: updateAttachmentRoute,
|
||||
delete: deleteAttachmentRoute,
|
||||
find: findAttachmentsRoute,
|
||||
},
|
||||
});
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { createAttachment } from '@documenso/lib/server-only/envelope-attachment/create-attachment';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZCreateAttachmentRequestSchema,
|
||||
ZCreateAttachmentResponseSchema,
|
||||
} from './create-attachment.types';
|
||||
|
||||
export const createAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/create',
|
||||
summary: 'Create attachment',
|
||||
description: 'Create a new attachment for an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
.input(ZCreateAttachmentRequestSchema)
|
||||
.output(ZCreateAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const { envelopeId, data } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { envelopeId, label: data.label },
|
||||
});
|
||||
|
||||
await createAttachment({
|
||||
envelopeId,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateAttachmentRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZCreateAttachmentResponseSchema = z.void();
|
||||
|
||||
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
|
||||
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;
|
||||
@ -0,0 +1,36 @@
|
||||
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZDeleteAttachmentRequestSchema,
|
||||
ZDeleteAttachmentResponseSchema,
|
||||
} from './delete-attachment.types';
|
||||
|
||||
export const deleteAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/delete',
|
||||
summary: 'Delete attachment',
|
||||
description: 'Delete an attachment from an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
.input(ZDeleteAttachmentRequestSchema)
|
||||
.output(ZDeleteAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const { id } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { id },
|
||||
});
|
||||
|
||||
await deleteAttachment({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeleteAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteAttachmentResponseSchema = z.void();
|
||||
|
||||
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
|
||||
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;
|
||||
@ -0,0 +1,52 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id';
|
||||
import { findAttachmentsByToken } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-token';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZFindAttachmentsRequestSchema,
|
||||
ZFindAttachmentsResponseSchema,
|
||||
} from './find-attachments.types';
|
||||
|
||||
export const findAttachmentsRoute = procedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/attachment',
|
||||
summary: 'Find attachments',
|
||||
description: 'Find all attachments for an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
.input(ZFindAttachmentsRequestSchema)
|
||||
.output(ZFindAttachmentsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { envelopeId, token } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { envelopeId },
|
||||
});
|
||||
|
||||
if (token) {
|
||||
const data = await findAttachmentsByToken({ envelopeId, token });
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId || !teamId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You must be authenticated to access this resource',
|
||||
});
|
||||
}
|
||||
|
||||
const data = await findAttachmentsByEnvelopeId({ envelopeId, teamId, userId });
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
|
||||
export const ZFindAttachmentsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZFindAttachmentsResponseSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: ZEnvelopeAttachmentTypeSchema,
|
||||
label: z.string(),
|
||||
data: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TFindAttachmentsRequest = z.infer<typeof ZFindAttachmentsRequestSchema>;
|
||||
export type TFindAttachmentsResponse = z.infer<typeof ZFindAttachmentsResponseSchema>;
|
||||
@ -0,0 +1,37 @@
|
||||
import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment/update-attachment';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZUpdateAttachmentRequestSchema,
|
||||
ZUpdateAttachmentResponseSchema,
|
||||
} from './update-attachment.types';
|
||||
|
||||
export const updateAttachmentRoute = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/attachment/update',
|
||||
summary: 'Update attachment',
|
||||
description: 'Update an existing attachment',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
})
|
||||
.input(ZUpdateAttachmentRequestSchema)
|
||||
.output(ZUpdateAttachmentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const { id, data } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { id },
|
||||
});
|
||||
|
||||
await updateAttachment({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
data,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZUpdateAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
data: z.string().url('Must be a valid URL'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateAttachmentResponseSchema = z.void();
|
||||
|
||||
export type TUpdateAttachmentRequest = z.infer<typeof ZUpdateAttachmentRequestSchema>;
|
||||
export type TUpdateAttachmentResponse = z.infer<typeof ZUpdateAttachmentResponseSchema>;
|
||||
@ -9,7 +9,7 @@ import {
|
||||
} from './create-envelope.types';
|
||||
|
||||
export const createEnvelopeRoute = authenticatedProcedure
|
||||
.input(ZCreateEnvelopeRequestSchema) // Note: Before releasing this to public, update the response schema to be correct.
|
||||
.input(ZCreateEnvelopeRequestSchema)
|
||||
.output(ZCreateEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
@ -24,6 +24,7 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
folderId,
|
||||
items,
|
||||
meta,
|
||||
attachments,
|
||||
} = input;
|
||||
|
||||
ctx.logger.info({
|
||||
@ -57,6 +58,7 @@ export const createEnvelopeRoute = authenticatedProcedure
|
||||
folderId,
|
||||
envelopeItems: items,
|
||||
},
|
||||
attachments,
|
||||
meta,
|
||||
normalizePdf: true,
|
||||
requestMetadata: ctx.metadata,
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
|
||||
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
import {
|
||||
ZFieldHeightSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
@ -76,6 +77,15 @@ export const ZCreateEnvelopeRequestSchema = z.object({
|
||||
)
|
||||
.optional(),
|
||||
meta: ZDocumentMetaCreateSchema.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 const ZCreateEnvelopeResponseSchema = z.object({
|
||||
|
||||
@ -35,7 +35,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
emailSettings: meta.emailSettings,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
emailReplyTo: meta.emailReplyTo,
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { router } from '../trpc';
|
||||
import { createAttachmentRoute } from './attachment/create-attachment';
|
||||
import { deleteAttachmentRoute } from './attachment/delete-attachment';
|
||||
import { findAttachmentsRoute } from './attachment/find-attachments';
|
||||
import { updateAttachmentRoute } from './attachment/update-attachment';
|
||||
import { createEnvelopeRoute } from './create-envelope';
|
||||
import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
@ -35,4 +39,10 @@ export const envelopeRouter = router({
|
||||
set: setEnvelopeFieldsRoute,
|
||||
sign: signEnvelopeFieldRoute,
|
||||
},
|
||||
attachment: {
|
||||
find: findAttachmentsRoute,
|
||||
create: createAttachmentRoute,
|
||||
update: updateAttachmentRoute,
|
||||
delete: deleteAttachmentRoute,
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,27 +2,26 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
|
||||
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
|
||||
import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
|
||||
import { findFoldersInternal } from '@documenso/lib/server-only/folder/find-folders-internal';
|
||||
import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-folder-breadcrumbs';
|
||||
import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id';
|
||||
import { moveFolder } from '@documenso/lib/server-only/folder/move-folder';
|
||||
import { pinFolder } from '@documenso/lib/server-only/folder/pin-folder';
|
||||
import { unpinFolder } from '@documenso/lib/server-only/folder/unpin-folder';
|
||||
import { updateFolder } from '@documenso/lib/server-only/folder/update-folder';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateFolderSchema,
|
||||
ZDeleteFolderSchema,
|
||||
ZCreateFolderRequestSchema,
|
||||
ZCreateFolderResponseSchema,
|
||||
ZDeleteFolderRequestSchema,
|
||||
ZFindFoldersInternalRequestSchema,
|
||||
ZFindFoldersInternalResponseSchema,
|
||||
ZFindFoldersRequestSchema,
|
||||
ZFindFoldersResponseSchema,
|
||||
ZGenericSuccessResponse,
|
||||
ZGetFoldersResponseSchema,
|
||||
ZGetFoldersSchema,
|
||||
ZMoveFolderSchema,
|
||||
ZPinFolderSchema,
|
||||
ZSuccessResponseSchema,
|
||||
ZUnpinFolderSchema,
|
||||
ZUpdateFolderSchema,
|
||||
ZUpdateFolderRequestSchema,
|
||||
ZUpdateFolderResponseSchema,
|
||||
} from './schema';
|
||||
|
||||
export const folderRouter = router({
|
||||
@ -43,7 +42,7 @@ export const folderRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
const folders = await findFolders({
|
||||
const folders = await findFoldersInternal({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
@ -67,11 +66,47 @@ export const folderRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
findFolders: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/folder',
|
||||
summary: 'Find folders',
|
||||
description: 'Find folders based on a search criteria',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZFindFoldersRequestSchema)
|
||||
.output(ZFindFoldersResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { parentId, type, page, perPage } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
parentId,
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
return await findFolders({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
findFoldersInternal: authenticatedProcedure
|
||||
.input(ZFindFoldersInternalRequestSchema)
|
||||
.output(ZFindFoldersInternalResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { parentId, type } = input;
|
||||
@ -83,7 +118,7 @@ export const folderRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
const folders = await findFolders({
|
||||
const folders = await findFoldersInternal({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
@ -107,10 +142,20 @@ export const folderRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
createFolder: authenticatedProcedure
|
||||
.input(ZCreateFolderSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/create',
|
||||
summary: 'Create new folder',
|
||||
description: 'Creates a new folder in your team',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZCreateFolderRequestSchema)
|
||||
.output(ZCreateFolderResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { name, parentId, type } = input;
|
||||
@ -145,181 +190,77 @@ export const folderRouter = router({
|
||||
type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type,
|
||||
};
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
updateFolder: authenticatedProcedure
|
||||
.input(ZUpdateFolderSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/update',
|
||||
summary: 'Update folder',
|
||||
description: 'Updates an existing folder',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZUpdateFolderRequestSchema)
|
||||
.output(ZUpdateFolderResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { id, name, visibility } = input;
|
||||
const { folderId, data } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
});
|
||||
|
||||
const result = await updateFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
name,
|
||||
visibility,
|
||||
type: currentFolder.type,
|
||||
folderId,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @public
|
||||
*/
|
||||
deleteFolder: authenticatedProcedure
|
||||
.input(ZDeleteFolderSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/delete',
|
||||
summary: 'Delete folder',
|
||||
description: 'Deletes an existing folder',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZDeleteFolderRequestSchema)
|
||||
.output(ZSuccessResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { id } = input;
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
folderId,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
moveFolder: authenticatedProcedure.input(ZMoveFolderSchema).mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { id, parentId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
});
|
||||
|
||||
if (parentId !== null) {
|
||||
try {
|
||||
await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: parentId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await moveFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
parentId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
pinFolder: authenticatedProcedure.input(ZPinFolderSchema).mutation(async ({ ctx, input }) => {
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const result = await pinFolder({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
unpinFolder: authenticatedProcedure.input(ZUnpinFolderSchema).mutation(async ({ ctx, input }) => {
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const result = await unpinFolder({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user