Compare commits

...

16 Commits

Author SHA1 Message Date
Mythie
3b8914da83 v1.6.1-rc.1 2024-08-13 09:57:50 +10:00
Catalin Pit
29910ab2a7 feat: add initials field type (#1279)
Adds a new field type that enables document recipients to add
their `initials` on the document.
2024-08-12 23:29:32 +10:00
Mythie
ef3ecc33f1 v1.6.1-rc.0 2024-08-09 15:49:41 +10:00
Catalin Pit
0244f021ab fix: download audit log certificate (#1268)
Previously, it wasn't possible to download an audit log of a document
uploaded by another user because the function used the ID of the user
making the request to retrieve the document. However, the document
uploaded by another user has that user's ID, not the ID of the user
making the request.
2024-08-09 12:19:48 +10:00
Lucas Smith
e5f73452b3 fix: support dynamic external ids for direct templates (#1274)
Adds support for an `externalId` query param to be passed when linking a
user to a direct template. This external id will then be stored on the
document upon signing completion.
2024-08-09 11:06:17 +10:00
Catalin Pit
c605877924 fix: radio and checkbox fields issues with empty values (#1273)
Since we allow checkboxes and radio fields without a label (which we use for the value) we 
had an issue where multiple checkboxes with no value would exist and items would not end
up checked on the resulting document.

This change fixes that by adding a placeholder value for these empty checkboxes and labels.
2024-08-09 10:46:07 +10:00
Ephraim Duncan
e0065a8731 fix: show signup option only to users without existing accounts (#1221)
Changes the signup CTA to only display if the recipient doesn't already have an account.
2024-08-08 10:47:00 +10:00
Jacob Flaherty
f74265850b chore: Update .env.example commenting (#1257)
Adds documentation on how to setup Google for OAuth when self hosting Documenso.
2024-08-08 09:58:27 +10:00
Catalin Pit
909c38f47e fix: fields name/label on dark mode (#1242)
Updates the dark mode styling for field editing to improve readability.
2024-07-31 23:07:39 +10:00
emmpaz
1beb434a72 fix: limits syncing issue (#1195)
Exposes `refreshLimits()` to be able to keep the limit in sync when
deleting/creating a document.
2024-07-31 22:57:27 +10:00
Rene Steen
5582f29bda feat: make oidc sign in button text configurable (#1209)
Adds a configurable label for the OIDC connection's button.
2024-07-31 22:22:52 +10:00
Rene Steen
7ed0a909eb feat: allow oidc only signup and trust mail addresses (#1208)
This change will allow for user registration when users are federated
through oidc provider even if the general signup is disabled
additionally the users email address can now be automatically set as
trusted. This will force corporate users to signin using SSO instead of
creating manual accounts.
2024-07-31 15:38:12 +10:00
aeris
a9025b5d97 fix: use native URL parser instead of wrong regex (#1206)
Updates the current regex based approach for validating redirect urls to 
instead use the native URL constructor which is available in browsers and 
Node.js and handles several valid cases that were previously not working.
2024-07-31 15:26:05 +10:00
Timur Ercan
0c744a1123 chore: last touches (#1263)
fair use policy and support page

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added a "Support" documentation page outlining various support options
for users, including Community, Paid Account, and Enterprise support.
- Introduced a "Fair Use Policy" documentation page, providing
guidelines and reassurance regarding usage of the service.
- Enhanced the account creation documentation with additional
information about the Fair Use Policy.

- **Improvements**
- Updated the documentation structure with new sections for easier
navigation, including "Support" and "Fair Use Policy."

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-07-30 18:21:58 +02:00
Ephraim Duncan
0f86eed6ac fix: add new environmental variables for render deployment (#1174)
Updates the `render.yaml` configuration file by adding new environment
variables
2024-07-30 16:14:31 +10:00
Catalin Pit
4b485268ca fix: dateformat api bug (#1238)
Fixes support for date formats when using the API
2024-07-30 16:13:22 +10:00
56 changed files with 687 additions and 582 deletions

View File

@@ -10,12 +10,19 @@ NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# [[AUTH OPTIONAL]]
# Find documentation on setting up Google OAuth here:
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
# This can be used to still allow signups for OIDC connections
# when signup is disabled via `NEXT_PUBLIC_DISABLE_SIGNUP`
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP=""
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"

View File

@@ -1,5 +1,6 @@
{
"index": "Getting Started",
"signing-certificate": "Signing Certificate",
"how-to": "How To"
}
"how-to": "How To",
"setting-up-oauth-providers": "Setting up OAuth Providers"
}

View File

@@ -0,0 +1,29 @@
---
title: Setting up OAuth Providers
description: Learn how to set up OAuth providers for your own instance of Documenso.
---
## Google OAuth (Gmail)
To use Google OAuth, you will need to create a Google Cloud Platform project and enable the Google Identity and Access Management (IAM) API. You will also need to create a new OAuth client ID and download the client secret.
### Create and configure a new OAuth client ID
1. Go to the [Google Cloud Platform Console](https://console.cloud.google.com/)
2. From the projects list, select a project or create a new one
3. If the APIs & services page isn't already open, open the console left side menu and select APIs & services
4. On the left, click Credentials
5. Click New Credentials, then select OAuth client ID
6. When prompted to select an application type, select Web application
7. Enter a name for your client ID, and click Create
8. Click the download button to download the client secret
9. Set the authorized javascript origins to `https://<documenso-domain>`
10. Set the authorized redirect URIs to `https://<documenso-domain>/api/auth/callback/google`
11. In the Documenso environment variables, set the following:
```
NEXT_PRIVATE_GOOGLE_CLIENT_ID=<your-client-id>
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.

View File

@@ -1,5 +1,6 @@
{
"index": "Introduction",
"support": "Support",
"-- How To Use": {
"type": "separator",
"title": "How To Use"
@@ -13,6 +14,7 @@
"type": "separator",
"title": "Legal Overview"
},
"fair-use": "Fair Use Policy",
"licenses": "Licenses",
"compliance": "Compliance"
}

View File

@@ -0,0 +1,34 @@
---
title: Fair Use Policy
description: Learn about our fair use policy, which enables us to have unlimited plans.
---
import { Callout } from 'nextra/components';
# Fair Use Policy
### Why
We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy:
### Spirit of the Plan
> Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account.
<Callout type="info">
What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom
pricing. We wont block your account without reaching out. [Message
us](mailto:support@documenso.com) for questions. It's probably fine, though.
</Callout>
### DO
- Sign as many documents with the individual plan for your single business or organization you are part of
- Use the API and Zapier to automate all your signing to sign as much as possible
- Experiment with the plans and integrations, testing what you want to build: When in doubt, do it. Especially if you are just starting.
### DON'T
- Use the individual account's API to power a platform
- Run a huge company, signing thousands of documents per day on a two-user team plan using the API
- Let this policy make you overthink. If you are a paying customer, we want you to win, and it's probably fine

View File

@@ -3,7 +3,7 @@ title: Create Your Account
description: Learn how to create an account on Documenso.
---
import { Steps } from 'nextra/components';
import { Callout, Steps } from 'nextra/components';
# Create Your Account
@@ -14,6 +14,8 @@ The first step to start using Documenso is to pick a plan and create an account.
Explore each plan's features and choose the one that best suits your needs. The [pricing page](https://documen.so/pricing) has more information about the plans.
<Callout>All plans are subject to our [Fair Use Policy](/users/fair-use).</Callout>
### Create an account
If you are unsure which plan to choose, you can start with the free plan and upgrade later.

View File

@@ -0,0 +1,38 @@
---
title: Support
description: Learn what types of support we offer.
---
# Support
## Community Support
If you are a developer or free user, you can reach out to the community or raise an issue:
### [Create Github Issues](https://github.com/documenso/documenso/issues)
The community and the core team address GitHub issues. Be sure to check if a similar issue already exists. Please note that while we want to address everything immediately, we must prioritize.
### [Join our Discord](https://documen.so/discord)
You can ask for help in the [community help channel](https://discord.com/channels/1132216843537485854/1133419426524430376).
## Paid Account Support
### Email: support@documenso.com
If you are paying customers facing issues, email our customer support, especially in urgent cases.
### Private Discord channel
If you prefer Discord, we can invite you to a private channel. Message support to make this happen.
## Enterprise Support
### Email: support@documenso.com
If you are paying customers facing issues, email our customer support, especially in urgent cases.
### Slack
If your team is on Slack, we can create a private workspace to support you more closely.

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.6.0",
"version": "1.6.1-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -64,4 +64,4 @@
"next": "$next"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.6.0",
"version": "1.6.1-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -81,4 +81,4 @@
"next": "$next"
}
}
}
}

View File

@@ -16,5 +16,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP?: string;
NEXT_PRIVATE_OIDC_SKIP_VERIFY?: string;
}
}

View File

@@ -139,7 +139,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
documentStatus={document.status}
/>
<DownloadAuditLogButton documentId={document.id} />
<DownloadAuditLogButton teamId={team?.id} documentId={document.id} />
</div>
</div>

View File

@@ -9,10 +9,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadAuditLogButtonProps = {
className?: string;
teamId?: number;
documentId: number;
};
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
export const DownloadAuditLogButton = ({
className,
teamId,
documentId,
}: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadAuditLogs, isLoading } =
@@ -20,7 +25,7 @@ export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditL
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const { url } = await downloadAuditLogs({ teamId, documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -41,6 +42,7 @@ export const DeleteDocumentDialog = ({
const router = useRouter();
const { toast } = useToast();
const { refreshLimits } = useLimits();
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
@@ -48,6 +50,7 @@ export const DeleteDocumentDialog = ({
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
onSuccess: () => {
router.refresh();
void refreshLimits();
toast({
title: 'Document deleted',

View File

@@ -36,7 +36,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const { quota, remaining, refreshLimits } = useLimits();
const [isLoading, setIsLoading] = useState(false);
@@ -71,6 +71,8 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
teamId: team?.id,
});
void refreshLimits();
toast({
title: 'Document uploaded',
description: 'Your document has been uploaded successfully.',

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { Field } from '@documenso/prisma/client';
@@ -39,6 +39,7 @@ export const DirectTemplatePageView = ({
directTemplateToken,
}: TemplatesDirectPageViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
@@ -82,8 +83,15 @@ export const DirectTemplatePageView = ({
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
const token = await createDocumentFromDirectTemplate({
directTemplateToken,
directTemplateExternalId,
directRecipientName: fullName,
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,

View File

@@ -41,6 +41,7 @@ import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { InitialsField } from '~/app/(signing)/sign/[token]/initials-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
@@ -182,6 +183,15 @@ export const SignDirectTemplateForm = ({
onUnsignField={onUnsignField}
/>
))
.with(FieldType.INITIALS, () => (
<InitialsField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
))
.with(FieldType.NAME, () => (
<NameField
key={field.id}

View File

@@ -13,6 +13,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -77,6 +78,9 @@ export default async function CompletedSigningPage({
}
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
const isExistingUser = await getUserByEmail({ email: recipient.email })
.then((u) => !!u)
.catch(() => false);
const recipientName =
recipient.name ||
@@ -85,7 +89,7 @@ export default async function CompletedSigningPage({
const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user;
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
const canSignUp = !isExistingUser && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
return (
<div

View File

@@ -0,0 +1,140 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
export type InitialsFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const InitialsField = ({
field,
recipient,
onSignField,
onUnsignField,
}: InitialsFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { fullName } = useRequiredSigningContext();
const initials = extractInitials(fullName);
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const value = initials ?? '';
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: false,
authOptions,
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Initials">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
Initials
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
{field.customText}
</p>
)}
</SigningFieldContainer>
);
};

View File

@@ -39,7 +39,16 @@ export type SignatureFieldProps = {
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
onRemove?: (fieldType?: string) => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
type?:
| 'Date'
| 'Initials'
| 'Email'
| 'Name'
| 'Signature'
| 'Radio'
| 'Dropdown'
| 'Number'
| 'Checkbox';
tooltipText?: string | null;
};

View File

@@ -26,6 +26,7 @@ import { DateField } from './date-field';
import { DropdownField } from './dropdown-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
import { InitialsField } from './initials-field';
import { NameField } from './name-field';
import { NumberField } from './number-field';
import { RadioField } from './radio-field';
@@ -101,6 +102,9 @@ export const SigningPageView = ({
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.INITIALS, () => (
<InitialsField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))

View File

@@ -4,7 +4,11 @@ import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignInForm } from '~/components/forms/signin';
@@ -43,6 +47,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
oidcProviderLabel={OIDC_PROVIDER_LABEL}
/>
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

View File

@@ -98,6 +98,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
{
type: P.union(
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,

View File

@@ -71,6 +71,7 @@ export type SignInFormProps = {
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
};
export const SignInForm = ({
@@ -78,6 +79,7 @@ export const SignInForm = ({
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
}: SignInFormProps) => {
const { toast } = useToast();
const { getFlag } = useFeatureFlags();
@@ -369,7 +371,7 @@ export const SignInForm = ({
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
OIDC
{oidcProviderLabel || 'OIDC'}
</Button>
)}

View File

@@ -60,13 +60,23 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
},
});
},
linkAccount: async ({ user }) => {
linkAccount: async ({ user, account, profile }) => {
const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
if (isNaN(userId)) {
if (Number.isNaN(userId)) {
return;
}
// If the user is linking an OIDC account and the email verified date is set then update it in the db.
if (account.provider === 'oidc' && profile.emailVerified !== null) {
await prisma.user.update({
where: { id: userId },
data: {
emailVerified: profile.emailVerified,
},
});
}
await prisma.userSecurityAuditLog.create({
data: {
userId,

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.6.0",
"version": "1.6.1-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.6.0",
"version": "1.6.1-rc.1",
"workspaces": [
"apps/*",
"packages/*"
@@ -80,7 +80,7 @@
},
"apps/marketing": {
"name": "@documenso/marketing",
"version": "1.6.0",
"version": "1.6.1-rc.1",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/assets": "*",
@@ -424,7 +424,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.6.0",
"version": "1.6.1-rc.1",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/api": "*",

View File

@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.6.0",
"version": "1.6.1-rc.1",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
@@ -80,4 +80,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@@ -2,6 +2,9 @@ import { createNextRoute } from '@ts-rest/next';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import '@documenso/lib/constants/time-zones';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
@@ -222,6 +225,36 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
const dateFormat = body.meta.dateFormat
? DATE_FORMATS.find((format) => format.label === body.meta.dateFormat)
: DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT);
const timezone = body.meta.timezone
? TIME_ZONES.find((tz) => tz === body.meta.timezone)
: DEFAULT_DOCUMENT_TIME_ZONE;
const isDateFormatValid = body.meta.dateFormat
? DATE_FORMATS.some((format) => format.label === dateFormat?.label)
: true;
const isTimeZoneValid = body.meta.timezone ? TIME_ZONES.includes(String(timezone)) : true;
if (!isDateFormatValid) {
return {
status: 400,
body: {
message: 'Invalid date format. Please provide a valid date format',
},
};
}
if (!isTimeZoneValid) {
return {
status: 400,
body: {
message: 'Invalid timezone. Please provide a valid timezone',
},
};
}
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
@@ -244,7 +277,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
subject: body.meta.subject,
message: body.meta.message,
timezone,
dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl,
requestMetadata: extractNextApiRequestMetadata(args.req),
});

View File

@@ -1,5 +1,9 @@
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import { z } from 'zod';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import '@documenso/lib/constants/time-zones';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import {
DocumentDataType,
@@ -11,6 +15,8 @@ import {
TemplateType,
} from '@documenso/prisma/client';
extendZodWithOpenApi(z);
export const ZNoBodyMutationSchema = null;
/**
@@ -97,8 +103,19 @@ export const ZCreateDocumentMutationSchema = z.object({
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
timezone: z.string().default(DEFAULT_DOCUMENT_TIME_ZONE).openapi({
description:
'The timezone of the date. Must be one of the options listed in the list below.',
enum: TIME_ZONES,
}),
dateFormat: z
.string()
.default(DEFAULT_DOCUMENT_DATE_FORMAT)
.openapi({
description:
'The format of the date. Must be one of the options listed in the list below.',
enum: DATE_FORMATS.map((format) => format.value),
}),
redirectUrl: z.string(),
})
.partial(),

View File

@@ -1,6 +1,6 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { equals } from 'remeda';
@@ -8,7 +8,7 @@ import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
import type { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema;
export type LimitsContextValue = TLimitsResponseSchema & { refreshLimits: () => Promise<void> };
const LimitsContext = createContext<LimitsContextValue | null>(null);
@@ -23,7 +23,7 @@ export const useLimits = () => {
};
export type LimitsProviderProps = {
initialValue?: LimitsContextValue;
initialValue?: TLimitsResponseSchema;
teamId?: number;
children?: React.ReactNode;
};
@@ -38,7 +38,7 @@ export const LimitsProvider = ({
}: LimitsProviderProps) => {
const [limits, setLimits] = useState(() => initialValue);
const refreshLimits = async () => {
const refreshLimits = useCallback(async () => {
const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
@@ -48,11 +48,11 @@ export const LimitsProvider = ({
return newLimits;
});
};
}, [teamId]);
useEffect(() => {
void refreshLimits();
}, []);
}, [refreshLimits]);
useEffect(() => {
const onFocus = () => {
@@ -64,7 +64,16 @@ export const LimitsProvider = ({
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
}, [refreshLimits]);
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
return (
<LimitsContext.Provider
value={{
...limits,
refreshLimits,
}}
>
{children}
</LimitsContext.Provider>
);
};

View File

@@ -3,7 +3,6 @@
import { headers } from 'next/headers';
import { getLimits } from '../client';
import type { LimitsContextValue } from './client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
@@ -14,7 +13,7 @@ export type LimitsProviderProps = {
export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId });
const limits = await getLimits({ headers: requestHeaders, teamId });
return (
<ClientLimitsProvider initialValue={limits} teamId={teamId}>

View File

@@ -18,6 +18,8 @@ export const IS_OIDC_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_OIDC_CLIENT_SECRET,
);
export const OIDC_PROVIDER_LABEL = process.env.NEXT_PRIVATE_OIDC_PROVIDER_LABEL;
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',

View File

@@ -1,2 +0,0 @@
export const URL_REGEX =
/^(https?):\/\/(?:www\.)?(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;

View File

@@ -161,7 +161,10 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
id: profile.sub,
email: profile.email || profile.preferred_username,
name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(),
emailVerified: profile.email_verified ? new Date().toISOString() : null,
emailVerified:
process.env.NEXT_PRIVATE_OIDC_SKIP_VERIFY === 'true' || profile.email_verified
? new Date().toISOString()
: null,
};
},
},
@@ -361,6 +364,12 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
},
async signIn({ user }) {
// This statement appears above so we can stil allow `oidc` connections
// while other signups are disabled.
if (env('NEXT_PRIVATE_OIDC_ALLOW_SIGNUP') === 'true') {
return true;
}
// We do this to stop OAuth providers from creating an account
// when signups are disabled
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {

View File

@@ -1,12 +1,12 @@
import { z } from 'zod';
import { URL_REGEX } from '../constants/url-regex';
import { isValidRedirectUrl } from '../utils/is-valid-redirect-url';
/**
* Note this allows empty strings.
*/
export const ZUrlSchema = z
.string()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
});

View File

@@ -231,10 +231,17 @@ export const signFieldWithToken = async ({
type,
data: signatureImageAsBase64 || typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: updatedField.customText,
}))
.with(
FieldType.DATE,
FieldType.EMAIL,
FieldType.NAME,
FieldType.TEXT,
FieldType.INITIALS,
(type) => ({
type,
data: updatedField.customText,
}),
)
.with(
FieldType.NUMBER,
FieldType.RADIO,

View File

@@ -133,9 +133,14 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
throw new Error('Invalid checkbox field meta');
}
const values = meta.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected = field.customText.split(',');
for (const [index, item] of (meta.data.values ?? []).entries()) {
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
@@ -169,9 +174,14 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
throw new Error('Invalid radio field meta');
}
const values = meta?.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected = field.customText.split(',');
for (const [index, item] of (meta.data.values ?? []).entries()) {
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);

View File

@@ -44,6 +44,7 @@ export type CreateDocumentFromDirectTemplateOptions = {
directRecipientName?: string;
directRecipientEmail: string;
directTemplateToken: string;
directTemplateExternalId?: string;
signedFieldValues: TSignFieldWithTokenMutationSchema[];
templateUpdatedAt: Date;
requestMetadata: RequestMetadata;
@@ -63,6 +64,7 @@ export const createDocumentFromDirectTemplate = async ({
directRecipientName: initialDirectRecipientName,
directRecipientEmail,
directTemplateToken,
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
requestMetadata,
@@ -227,6 +229,7 @@ export const createDocumentFromDirectTemplate = async ({
title: template.title,
createdAt: initialRequestTime,
status: DocumentStatus.PENDING,
externalId: directTemplateExternalId,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
@@ -465,6 +468,7 @@ export const createDocumentFromDirectTemplate = async ({
.with(
FieldType.DATE,
FieldType.EMAIL,
FieldType.INITIALS,
FieldType.NAME,
FieldType.TEXT,
FieldType.NUMBER,

View File

@@ -233,6 +233,10 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
// Organised into union to allow us to extend each field if required.
field: z.union([
z.object({
type: z.literal(FieldType.INITIALS),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.EMAIL),
data: z.string(),

View File

@@ -0,0 +1,16 @@
const ALLOWED_PROTOCOLS = ['http', 'https'];
export const isValidRedirectUrl = (value: string) => {
try {
const url = new URL(value);
console.log({ protocol: url.protocol });
if (!ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase())) {
return false;
}
return true;
} catch {
return false;
}
};

View File

@@ -1,481 +0,0 @@
import type { ColumnType } from 'kysely';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export const IdentityProvider = {
DOCUMENSO: 'DOCUMENSO',
GOOGLE: 'GOOGLE',
OIDC: 'OIDC',
} as const;
export type IdentityProvider = (typeof IdentityProvider)[keyof typeof IdentityProvider];
export const Role = {
ADMIN: 'ADMIN',
USER: 'USER',
} as const;
export type Role = (typeof Role)[keyof typeof Role];
export const UserSecurityAuditLogType = {
ACCOUNT_PROFILE_UPDATE: 'ACCOUNT_PROFILE_UPDATE',
ACCOUNT_SSO_LINK: 'ACCOUNT_SSO_LINK',
AUTH_2FA_DISABLE: 'AUTH_2FA_DISABLE',
AUTH_2FA_ENABLE: 'AUTH_2FA_ENABLE',
PASSKEY_CREATED: 'PASSKEY_CREATED',
PASSKEY_DELETED: 'PASSKEY_DELETED',
PASSKEY_UPDATED: 'PASSKEY_UPDATED',
PASSWORD_RESET: 'PASSWORD_RESET',
PASSWORD_UPDATE: 'PASSWORD_UPDATE',
SIGN_OUT: 'SIGN_OUT',
SIGN_IN: 'SIGN_IN',
SIGN_IN_FAIL: 'SIGN_IN_FAIL',
SIGN_IN_2FA_FAIL: 'SIGN_IN_2FA_FAIL',
SIGN_IN_PASSKEY_FAIL: 'SIGN_IN_PASSKEY_FAIL',
} as const;
export type UserSecurityAuditLogType =
(typeof UserSecurityAuditLogType)[keyof typeof UserSecurityAuditLogType];
export const WebhookTriggerEvents = {
DOCUMENT_CREATED: 'DOCUMENT_CREATED',
DOCUMENT_SENT: 'DOCUMENT_SENT',
DOCUMENT_OPENED: 'DOCUMENT_OPENED',
DOCUMENT_SIGNED: 'DOCUMENT_SIGNED',
DOCUMENT_COMPLETED: 'DOCUMENT_COMPLETED',
} as const;
export type WebhookTriggerEvents = (typeof WebhookTriggerEvents)[keyof typeof WebhookTriggerEvents];
export const WebhookCallStatus = {
SUCCESS: 'SUCCESS',
FAILED: 'FAILED',
} as const;
export type WebhookCallStatus = (typeof WebhookCallStatus)[keyof typeof WebhookCallStatus];
export const ApiTokenAlgorithm = {
SHA512: 'SHA512',
} as const;
export type ApiTokenAlgorithm = (typeof ApiTokenAlgorithm)[keyof typeof ApiTokenAlgorithm];
export const SubscriptionStatus = {
ACTIVE: 'ACTIVE',
PAST_DUE: 'PAST_DUE',
INACTIVE: 'INACTIVE',
} as const;
export type SubscriptionStatus = (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
export const DocumentStatus = {
DRAFT: 'DRAFT',
PENDING: 'PENDING',
COMPLETED: 'COMPLETED',
} as const;
export type DocumentStatus = (typeof DocumentStatus)[keyof typeof DocumentStatus];
export const DocumentSource = {
DOCUMENT: 'DOCUMENT',
TEMPLATE: 'TEMPLATE',
TEMPLATE_DIRECT_LINK: 'TEMPLATE_DIRECT_LINK',
} as const;
export type DocumentSource = (typeof DocumentSource)[keyof typeof DocumentSource];
export const DocumentDataType = {
S3_PATH: 'S3_PATH',
BYTES: 'BYTES',
BYTES_64: 'BYTES_64',
} as const;
export type DocumentDataType = (typeof DocumentDataType)[keyof typeof DocumentDataType];
export const ReadStatus = {
NOT_OPENED: 'NOT_OPENED',
OPENED: 'OPENED',
} as const;
export type ReadStatus = (typeof ReadStatus)[keyof typeof ReadStatus];
export const SendStatus = {
NOT_SENT: 'NOT_SENT',
SENT: 'SENT',
} as const;
export type SendStatus = (typeof SendStatus)[keyof typeof SendStatus];
export const SigningStatus = {
NOT_SIGNED: 'NOT_SIGNED',
SIGNED: 'SIGNED',
} as const;
export type SigningStatus = (typeof SigningStatus)[keyof typeof SigningStatus];
export const RecipientRole = {
CC: 'CC',
SIGNER: 'SIGNER',
VIEWER: 'VIEWER',
APPROVER: 'APPROVER',
} as const;
export type RecipientRole = (typeof RecipientRole)[keyof typeof RecipientRole];
export const FieldType = {
SIGNATURE: 'SIGNATURE',
FREE_SIGNATURE: 'FREE_SIGNATURE',
NAME: 'NAME',
EMAIL: 'EMAIL',
DATE: 'DATE',
TEXT: 'TEXT',
NUMBER: 'NUMBER',
RADIO: 'RADIO',
CHECKBOX: 'CHECKBOX',
DROPDOWN: 'DROPDOWN',
} as const;
export type FieldType = (typeof FieldType)[keyof typeof FieldType];
export const TeamMemberRole = {
ADMIN: 'ADMIN',
MANAGER: 'MANAGER',
MEMBER: 'MEMBER',
} as const;
export type TeamMemberRole = (typeof TeamMemberRole)[keyof typeof TeamMemberRole];
export const TeamMemberInviteStatus = {
ACCEPTED: 'ACCEPTED',
PENDING: 'PENDING',
} as const;
export type TeamMemberInviteStatus =
(typeof TeamMemberInviteStatus)[keyof typeof TeamMemberInviteStatus];
export const TemplateType = {
PUBLIC: 'PUBLIC',
PRIVATE: 'PRIVATE',
} as const;
export type TemplateType = (typeof TemplateType)[keyof typeof TemplateType];
export type Account = {
id: string;
userId: number;
type: string;
provider: string;
providerAccountId: string;
refresh_token: string | null;
access_token: string | null;
expires_at: number | null;
created_at: number | null;
ext_expires_in: number | null;
token_type: string | null;
scope: string | null;
id_token: string | null;
session_state: string | null;
};
export type AnonymousVerificationToken = {
id: string;
token: string;
expiresAt: Timestamp;
createdAt: Generated<Timestamp>;
};
export type ApiToken = {
id: Generated<number>;
name: string;
token: string;
algorithm: Generated<ApiTokenAlgorithm>;
expires: Timestamp | null;
createdAt: Generated<Timestamp>;
userId: number | null;
teamId: number | null;
};
export type Document = {
id: Generated<number>;
userId: number;
authOptions: unknown | null;
formValues: unknown | null;
title: string;
status: Generated<DocumentStatus>;
documentDataId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
completedAt: Timestamp | null;
deletedAt: Timestamp | null;
teamId: number | null;
templateId: number | null;
source: DocumentSource;
};
export type DocumentAuditLog = {
id: string;
documentId: number;
createdAt: Generated<Timestamp>;
type: string;
data: unknown;
name: string | null;
email: string | null;
userId: number | null;
userAgent: string | null;
ipAddress: string | null;
};
export type DocumentData = {
id: string;
type: DocumentDataType;
data: string;
initialData: string;
};
export type DocumentMeta = {
id: string;
subject: string | null;
message: string | null;
timezone: Generated<string | null>;
password: string | null;
dateFormat: Generated<string | null>;
documentId: number;
redirectUrl: string | null;
};
export type DocumentShareLink = {
id: Generated<number>;
email: string;
slug: string;
documentId: number;
createdAt: Generated<Timestamp>;
updatedAt: Timestamp;
};
export type Field = {
id: Generated<number>;
secondaryId: string;
documentId: number | null;
templateId: number | null;
recipientId: number;
type: FieldType;
page: number;
positionX: Generated<string>;
positionY: Generated<string>;
width: Generated<string>;
height: Generated<string>;
customText: string;
inserted: boolean;
fieldMeta: unknown | null;
};
export type Passkey = {
id: string;
userId: number;
name: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
lastUsedAt: Timestamp | null;
credentialId: Buffer;
credentialPublicKey: Buffer;
counter: string;
credentialDeviceType: string;
credentialBackedUp: boolean;
transports: string[];
};
export type PasswordResetToken = {
id: Generated<number>;
token: string;
createdAt: Generated<Timestamp>;
expiry: Timestamp;
userId: number;
};
export type Recipient = {
id: Generated<number>;
documentId: number | null;
templateId: number | null;
email: string;
name: Generated<string>;
token: string;
documentDeletedAt: Timestamp | null;
expired: Timestamp | null;
signedAt: Timestamp | null;
authOptions: unknown | null;
role: Generated<RecipientRole>;
readStatus: Generated<ReadStatus>;
signingStatus: Generated<SigningStatus>;
sendStatus: Generated<SendStatus>;
};
export type Session = {
id: string;
sessionToken: string;
userId: number;
expires: Timestamp;
};
export type Signature = {
id: Generated<number>;
created: Generated<Timestamp>;
recipientId: number;
fieldId: number;
signatureImageAsBase64: string | null;
typedSignature: string | null;
};
export type SiteSettings = {
id: string;
enabled: Generated<boolean>;
data: unknown;
lastModifiedByUserId: number | null;
lastModifiedAt: Generated<Timestamp>;
};
export type Subscription = {
id: Generated<number>;
status: Generated<SubscriptionStatus>;
planId: string;
priceId: string;
periodEnd: Timestamp | null;
userId: number | null;
teamId: number | null;
createdAt: Generated<Timestamp>;
updatedAt: Timestamp;
cancelAtPeriodEnd: Generated<boolean>;
};
export type Team = {
id: Generated<number>;
name: string;
url: string;
createdAt: Generated<Timestamp>;
customerId: string | null;
ownerUserId: number;
};
export type TeamEmail = {
teamId: number;
createdAt: Generated<Timestamp>;
name: string;
email: string;
};
export type TeamEmailVerification = {
teamId: number;
name: string;
email: string;
token: string;
expiresAt: Timestamp;
createdAt: Generated<Timestamp>;
};
export type TeamMember = {
id: Generated<number>;
teamId: number;
createdAt: Generated<Timestamp>;
role: TeamMemberRole;
userId: number;
};
export type TeamMemberInvite = {
id: Generated<number>;
teamId: number;
createdAt: Generated<Timestamp>;
email: string;
status: Generated<TeamMemberInviteStatus>;
role: TeamMemberRole;
token: string;
};
export type TeamPending = {
id: Generated<number>;
name: string;
url: string;
createdAt: Generated<Timestamp>;
customerId: string;
ownerUserId: number;
};
export type TeamTransferVerification = {
teamId: number;
userId: number;
name: string;
email: string;
token: string;
expiresAt: Timestamp;
createdAt: Generated<Timestamp>;
clearPaymentMethods: Generated<boolean>;
};
export type Template = {
id: Generated<number>;
type: Generated<TemplateType>;
title: string;
userId: number;
teamId: number | null;
authOptions: unknown | null;
templateDocumentDataId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
};
export type TemplateDirectLink = {
id: string;
templateId: number;
token: string;
createdAt: Generated<Timestamp>;
enabled: boolean;
directTemplateRecipientId: number;
};
export type TemplateMeta = {
id: string;
subject: string | null;
message: string | null;
timezone: Generated<string | null>;
password: string | null;
dateFormat: Generated<string | null>;
templateId: number;
redirectUrl: string | null;
};
export type User = {
id: Generated<number>;
name: string | null;
customerId: string | null;
email: string;
emailVerified: Timestamp | null;
password: string | null;
source: string | null;
signature: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
lastSignedIn: Generated<Timestamp>;
roles: Generated<Role[]>;
identityProvider: Generated<IdentityProvider>;
twoFactorSecret: string | null;
twoFactorEnabled: Generated<boolean>;
twoFactorBackupCodes: string | null;
url: string | null;
};
export type UserProfile = {
id: number;
bio: string | null;
};
export type UserSecurityAuditLog = {
id: Generated<number>;
userId: number;
createdAt: Generated<Timestamp>;
type: UserSecurityAuditLogType;
userAgent: string | null;
ipAddress: string | null;
};
export type VerificationToken = {
id: Generated<number>;
secondaryId: string;
identifier: string;
token: string;
expires: Timestamp;
createdAt: Generated<Timestamp>;
userId: number;
};
export type Webhook = {
id: string;
webhookUrl: string;
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
enabled: Generated<boolean>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
userId: number;
teamId: number | null;
};
export type WebhookCall = {
id: string;
status: WebhookCallStatus;
url: string;
event: WebhookTriggerEvents;
requestBody: unknown;
responseCode: number;
responseHeaders: unknown | null;
responseBody: unknown | null;
createdAt: Generated<Timestamp>;
webhookId: string;
};
export type DB = {
Account: Account;
AnonymousVerificationToken: AnonymousVerificationToken;
ApiToken: ApiToken;
Document: Document;
DocumentAuditLog: DocumentAuditLog;
DocumentData: DocumentData;
DocumentMeta: DocumentMeta;
DocumentShareLink: DocumentShareLink;
Field: Field;
Passkey: Passkey;
PasswordResetToken: PasswordResetToken;
Recipient: Recipient;
Session: Session;
Signature: Signature;
SiteSettings: SiteSettings;
Subscription: Subscription;
Team: Team;
TeamEmail: TeamEmail;
TeamEmailVerification: TeamEmailVerification;
TeamMember: TeamMember;
TeamMemberInvite: TeamMemberInvite;
TeamPending: TeamPending;
TeamTransferVerification: TeamTransferVerification;
Template: Template;
TemplateDirectLink: TemplateDirectLink;
TemplateMeta: TemplateMeta;
User: User;
UserProfile: UserProfile;
UserSecurityAuditLog: UserSecurityAuditLog;
VerificationToken: VerificationToken;
Webhook: Webhook;
WebhookCall: WebhookCall;
};

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "FieldType" ADD VALUE 'INITIALS';

View File

@@ -409,6 +409,7 @@ model Recipient {
enum FieldType {
SIGNATURE
FREE_SIGNATURE
INITIALS
NAME
EMAIL
DATE

View File

@@ -29,6 +29,7 @@ import {
ZCreateDocumentMutationSchema,
ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema,
ZDownloadAuditLogsMutationSchema,
ZDownloadCertificateMutationSchema,
ZFindDocumentAuditLogsQuerySchema,
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
@@ -411,7 +412,14 @@ export const documentRouter = router({
id: documentId,
userId: ctx.user.id,
teamId,
});
}).catch(() => null);
if (!document || (teamId && document.teamId !== teamId)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this document.',
});
}
const encrypted = encryptSecondaryData({
data: document.id.toString(),
@@ -433,7 +441,7 @@ export const documentRouter = router({
}),
downloadCertificate: authenticatedProcedure
.input(ZDownloadAuditLogsMutationSchema)
.input(ZDownloadCertificateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId } = input;

View File

@@ -1,11 +1,11 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({
@@ -65,8 +65,9 @@ export const ZSetSettingsForDocumentMutationSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message:
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
}),
});
@@ -131,8 +132,9 @@ export const ZSendDocumentMutationSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message:
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
}),
});
@@ -170,6 +172,11 @@ export const ZDownloadAuditLogsMutationSchema = z.object({
teamId: z.number().optional(),
});
export const ZDownloadCertificateMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().optional(),
});
export const ZMoveDocumentsToTeamSchema = z.object({
documentId: z.number(),
teamId: z.number(),

View File

@@ -66,6 +66,7 @@ export const templateRouter = router({
directRecipientName,
directRecipientEmail,
directTemplateToken,
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
} = input;
@@ -76,6 +77,7 @@ export const templateRouter = router({
directRecipientName,
directRecipientEmail,
directTemplateToken,
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
user: ctx.user

View File

@@ -1,11 +1,11 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { TemplateType } from '@documenso/prisma/client';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
@@ -20,6 +20,7 @@ export const ZCreateDocumentFromDirectTemplateMutationSchema = z.object({
directRecipientName: z.string().optional(),
directRecipientEmail: z.string().email(),
directTemplateToken: z.string().min(1),
directTemplateExternalId: z.string().optional(),
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
templateUpdatedAt: z.date(),
});
@@ -96,8 +97,9 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message:
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
})
.optional(),

View File

@@ -9,6 +9,9 @@ declare namespace NodeJS {
NEXT_PRIVATE_OIDC_WELL_KNOWN?: string;
NEXT_PRIVATE_OIDC_CLIENT_ID?: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET?: string;
NEXT_PRIVATE_OIDC_PROVIDER_LABEL?: string;
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP?: string;
NEXT_PRIVATE_OIDC_SKIP_VERIFY?: string;
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PRIVATE_ENCRYPTION_KEY: string;

View File

@@ -10,6 +10,7 @@ import {
CheckSquare,
ChevronDown,
ChevronsUpDown,
Contact,
Disc,
Hash,
Info,
@@ -457,10 +458,11 @@ export const AddFieldsFormPartial = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
selectedSignerStyles.default.base,
{
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
@@ -649,6 +651,32 @@ export const AddFieldsFormPartial = ({
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.INITIALS)}
onMouseDown={() => setSelectedField(FieldType.INITIALS)}
data-selected={selectedField === FieldType.INITIALS ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Contact className="h-4 w-4" />
Initials
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
@@ -662,7 +690,7 @@ export const AddFieldsFormPartial = ({
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="p-4">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',

View File

@@ -82,8 +82,12 @@ export const AddSettingsFormPartial = ({
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
timezone:
TIME_ZONES.find((timezone) => timezone === document.documentMeta?.timezone) ??
DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat:
DATE_FORMATS.find((format) => format.label === document.documentMeta?.dateFormat)
?.value ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
@@ -98,10 +102,20 @@ export const AddSettingsFormPartial = ({
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) {
if (
!form.formState.touchedFields.meta?.timezone &&
!documentHasBeenSent &&
!document.documentMeta?.timezone
) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]);
}, [
documentHasBeenSent,
form,
form.setValue,
form.formState.touchedFields.meta?.timezone,
document.documentMeta?.timezone,
]);
return (
<>

View File

@@ -2,11 +2,11 @@ import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
@@ -34,8 +34,9 @@ export const ZAddSettingsFormSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message:
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
}),
});

View File

@@ -1,4 +1,14 @@
import { CalendarDays, CheckSquare, ChevronDown, Disc, Hash, Mail, Type, User } from 'lucide-react';
import {
CalendarDays,
CheckSquare,
ChevronDown,
Contact,
Disc,
Hash,
Mail,
Type,
User,
} from 'lucide-react';
import type { TFieldMetaSchema as FieldMetaType } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
@@ -13,6 +23,7 @@ type FieldIconProps = {
};
const fieldIcons = {
[FieldType.INITIALS]: { icon: Contact, label: 'Initials' },
[FieldType.EMAIL]: { icon: Mail, label: 'Email' },
[FieldType.NAME]: { icon: User, label: 'Name' },
[FieldType.DATE]: { icon: CalendarDays, label: 'Date' },
@@ -46,9 +57,11 @@ export const FieldIcon = ({
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
label = fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
label =
fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
} else if (fieldMeta.label) {
label = fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
label =
fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
} else {
label = fieldIcons[type]?.label;
}
@@ -58,7 +71,7 @@ export const FieldIcon = ({
return (
<div className="text-field-card-foreground flex items-center justify-center gap-x-1.5 text-sm">
<Icon className='h-4 w-4' /> {label}
<Icon className="h-4 w-4" /> {label}
</div>
);
}

View File

@@ -46,6 +46,7 @@ export type TDocumentFlowFormSchema = z.infer<typeof ZDocumentFlowFormSchema>;
export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
[FieldType.SIGNATURE]: 'Signature',
[FieldType.FREE_SIGNATURE]: 'Free Signature',
[FieldType.INITIALS]: 'Initials',
[FieldType.TEXT]: 'Text',
[FieldType.DATE]: 'Date',
[FieldType.EMAIL]: 'Email',

View File

@@ -9,6 +9,7 @@ import {
CheckSquare,
ChevronDown,
ChevronsUpDown,
Contact,
Disc,
Hash,
Mail,
@@ -383,10 +384,11 @@ export const AddTemplateFieldsFormPartial = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
selectedSignerStyles.default.base,
{
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
@@ -546,6 +548,32 @@ export const AddTemplateFieldsFormPartial = ({
</Card>
</button>
<button
type="button"
className="group h-full w-full"
onClick={() => setSelectedField(FieldType.INITIALS)}
onMouseDown={() => setSelectedField(FieldType.INITIALS)}
data-selected={selectedField === FieldType.INITIALS ? true : undefined}
>
<Card
className={cn(
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
// selectedSignerStyles.borderClass,
)}
>
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
)}
>
<Contact className="h-4 w-4" />
Initials
</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"

View File

@@ -2,11 +2,11 @@ import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
@@ -27,8 +27,9 @@ export const ZAddTemplateSettingsFormSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message:
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
}),
});

View File

@@ -1,11 +1,11 @@
services:
- type: web
runtime: node
name: documenso-app
env: node
plan: free
buildCommand: npm i && npm run build:web
startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npm run start
healthCheckPath: /api/trpc/health
startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npx turbo run start --filter=@documenso/web
healthCheckPath: /api/health
envVars:
# Node Version
@@ -98,6 +98,62 @@ services:
- key: NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY
sync: false
# Crypto
- key: NEXT_PRIVATE_ENCRYPTION_KEY
generateValue: true
- key: NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY
generateValue: true
# Auth Optional
- key: NEXT_PRIVATE_GOOGLE_CLIENT_ID
sync: false
- key: NEXT_PRIVATE_GOOGLE_CLIENT_SECRET
sync: false
# Signing
- key: NEXT_PRIVATE_SIGNING_TRANSPORT
sync: false
- key: NEXT_PRIVATE_SIGNING_PASSPHRASE
sync: false
- key: NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH
sync: false
- key: NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
sync: false
# SMTP Optional
- key: NEXT_PRIVATE_SMTP_APIKEY_USER
sync: false
- key: NEXT_PRIVATE_SMTP_APIKEY
sync: false
- key: NEXT_PRIVATE_SMTP_SECURE
sync: false
- key: NEXT_PRIVATE_RESEND_API_KEY
sync: false
- key: NEXT_PRIVATE_MAILCHANNELS_API_KEY
sync: false
- key: NEXT_PRIVATE_MAILCHANNELS_ENDPOINT
sync: false
- key: NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN
sync: false
- key: NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR
sync: false
- key: NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY
sync: false
- key: NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT
sync: false
# Features Optional
- key: NEXT_PUBLIC_DISABLE_SIGNUP
sync: false
databases:
- name: documenso-db
plan: free

View File

@@ -2,20 +2,12 @@
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": [
"prebuild",
"^build"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
"dependsOn": ["prebuild", "^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"prebuild": {
"cache": false,
"dependsOn": [
"^prebuild"
]
"dependsOn": ["^prebuild"]
},
"lint": {
"cache": false
@@ -31,9 +23,7 @@
"persistent": true
},
"start": {
"dependsOn": [
"^build"
],
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
@@ -41,18 +31,14 @@
"cache": false
},
"test:e2e": {
"dependsOn": [
"^build"
],
"dependsOn": ["^build"],
"cache": false
},
"translate:compile": {
"cache": false
}
},
"globalDependencies": [
"**/.env.*local"
],
"globalDependencies": ["**/.env.*local"],
"globalEnv": [
"APP_VERSION",
"NEXT_PRIVATE_ENCRYPTION_KEY",
@@ -83,6 +69,9 @@
"NEXT_PRIVATE_OIDC_WELL_KNOWN",
"NEXT_PRIVATE_OIDC_CLIENT_ID",
"NEXT_PRIVATE_OIDC_CLIENT_SECRET",
"NEXT_PRIVATE_OIDC_PROVIDER_LABEL",
"NEXT_PRIVATE_OIDC_ALLOW_SIGNUP",
"NEXT_PRIVATE_OIDC_SKIP_VERIFY",
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
"NEXT_PRIVATE_UPLOAD_ENDPOINT",
"NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE",