Compare commits

..

1 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
7f9f7c4092 feat: use multiselect for webhook triggers 2025-02-18 18:02:31 +00:00
204 changed files with 16725 additions and 16796 deletions

View File

@@ -22,6 +22,7 @@ NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# [[URLS]] # [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
# URL used by the web app to request itself (e.g. local background jobs) # URL used by the web app to request itself (e.g. local background jobs)
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"

View File

@@ -1,7 +1,7 @@
name: Playwright Tests name: Playwright Tests
on: on:
push: push:
branches: ['main', 'feat/rr7'] branches: ['main']
pull_request: pull_request:
branches: ['main'] branches: ['main']
jobs: jobs:

View File

@@ -5,6 +5,7 @@ tasks:
cp .env.example .env && cp .env.example .env &&
set -a; source .env && set -a; source .env &&
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" && export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d command: npm run d
ports: ports:

View File

@@ -4,6 +4,9 @@
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
echo "Copying pdf.js"
npm run copy:pdfjs --workspace apps/**
echo "Copying .well-known/ contents" echo "Copying .well-known/ contents"
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs" node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"

View File

@@ -166,6 +166,7 @@ git clone https://github.com/<your-username>/documenso
- NEXTAUTH_SECRET - NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL - NEXT_PUBLIC_WEBAPP_URL
- NEXT_PUBLIC_MARKETING_URL
- NEXT_PRIVATE_DATABASE_URL - NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL - NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME - NEXT_PRIVATE_SMTP_FROM_NAME
@@ -236,6 +237,7 @@ The following environment variables must be set:
- `NEXTAUTH_SECRET` - `NEXTAUTH_SECRET`
- `NEXT_PUBLIC_WEBAPP_URL` - `NEXT_PUBLIC_WEBAPP_URL`
- `NEXT_PUBLIC_MARKETING_URL`
- `NEXT_PRIVATE_DATABASE_URL` - `NEXT_PRIVATE_DATABASE_URL`
- `NEXT_PRIVATE_DIRECT_DATABASE_URL` - `NEXT_PRIVATE_DIRECT_DATABASE_URL`
- `NEXT_PRIVATE_SMTP_FROM_NAME` - `NEXT_PRIVATE_SMTP_FROM_NAME`

View File

@@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start -p 3002", "start": "next start -p 3002",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules" "clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/assets": "*", "@documenso/assets": "*",

View File

@@ -34,6 +34,7 @@ Set up the following environment variables in the `.env` file:
```bash ```bash
NEXTAUTH_SECRET NEXTAUTH_SECRET
NEXT_PUBLIC_WEBAPP_URL NEXT_PUBLIC_WEBAPP_URL
NEXT_PUBLIC_MARKETING_URL
NEXT_PRIVATE_DATABASE_URL NEXT_PRIVATE_DATABASE_URL
NEXT_PRIVATE_DIRECT_DATABASE_URL NEXT_PRIVATE_DIRECT_DATABASE_URL
NEXT_PRIVATE_SMTP_FROM_NAME NEXT_PRIVATE_SMTP_FROM_NAME

View File

@@ -13,13 +13,35 @@ Documenso uses the following stack to handle translations:
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction). Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
## Requirements
You **must** insert **`setupI18nSSR()`** when creating any of the following files:
- Server layout.tsx
- Server page.tsx
- Server loading.tsx
Server meaning it does not have `'use client'` in it.
```tsx
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function SomePage() {
setupI18nSSR(); // Required if there are translations within the page, or nested in components.
// Rest of code...
}
```
Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui)
## Quick guide ## Quick guide
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction). If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
### HTML ### HTML
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/react/macro**. Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
```html ```html
<h1> <h1>
@@ -42,9 +64,8 @@ For text that is broken into elements, but represent a whole sentence, you must
### Constants outside of react components ### Constants outside of react components
```tsx ```tsx
import { msg } from '@lingui/core/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package. // Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
export const CONSTANT_WITH_MSG = { export const CONSTANT_WITH_MSG = {
@@ -77,13 +98,31 @@ Lingui provides a Plural component to make it easy. See full documentation [here
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale. Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
#### Server components
Note that the i18n instance is coming from **setupI18nSSR**.
```tsx ```tsx
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
export const SomeComponent = () => { export const SomeComponent = () => {
const { i18n } = useLingui(); const { i18n } = setupI18nSSR();
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>; return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
}; };
``` ```
#### Client components
Note that the i18n instance is coming from the **import**.
```tsx
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
export const SomeComponent = () => {
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
};
```

View File

@@ -21,25 +21,14 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
## API V2 - Beta ## API V2 - Beta
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout> Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods. <Callout type="warning">
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
Our new API V2 supports the following typed SDKs:
- [TypeScript](https://github.com/documenso/sdk-typescript)
- [Python](https://github.com/documenso/sdk-python)
- [Go](https://github.com/documenso/sdk-go)
<Callout type="info">
For the staging API, please use the following base URL:
`https://stg-app.documenso.dev/api/v2-beta/`
</Callout> </Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog) 🚀 [V2 Announcement](https://documen.so/sdk-blog)
📖 [Documentation](https://documen.so/api-v2-docs)
💬 [Leave Feedback](https://documen.so/sdk-feedback) 💬 [Leave Feedback](https://documen.so/sdk-feedback)
🔔 [Breaking Changes](https://documen.so/sdk-breaking) 🔔 [Breaking Changes](https://documen.so/sdk-breaking)

View File

@@ -37,6 +37,7 @@ Open the `.env` file and fill in the following variables:
```bash ```bash
- NEXTAUTH_SECRET - NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL - NEXT_PUBLIC_WEBAPP_URL
- NEXT_PUBLIC_MARKETING_URL
- NEXT_PRIVATE_DATABASE_URL - NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL - NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME - NEXT_PRIVATE_SMTP_FROM_NAME

View File

@@ -1,7 +1,7 @@
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely const qb = kyselyPrisma.$kysely

View File

@@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules" "clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/prisma": "*", "@documenso/prisma": "*",

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh #!/usr/bin/env bash
# Exit on error. # Exit on error.
set -eo pipefail set -eo pipefail

View File

@@ -1,77 +0,0 @@
#!/usr/bin/env bash
# Set Error handling
set -eu
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."
# Store the original directory
ORIGINAL_DIR=$(pwd)
# Set up trap to ensure we return to original directory
trap 'cd "$ORIGINAL_DIR"' EXIT
cd "$WEB_APP_DIR"
# Define env file paths
ENV_LOCAL_FILE="../../.env.local"
# Function to load environment variable from env files
load_env_var() {
local var_name=$1
local var_value=""
if [ -f "$ENV_LOCAL_FILE" ]; then
var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2)
fi
# Remove quotes if present
var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/")
echo "$var_value"
}
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED")
# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true
if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then
echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled."
exit 1
fi
# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files
NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY")
# Check if NEXT_PRIVATE_STRIPE_API_KEY exists
if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then
echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files."
echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE"
exit 1
fi
# 2. Check if stripe CLI is installed
if ! command -v stripe &> /dev/null; then
echo "[ERROR]: Stripe CLI is not installed or not in PATH."
echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli"
exit 1
fi
# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET")
if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
echo "╔═════════════════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║"
echo "║ ║"
echo "║ Copy the webhook signing secret which will appear in the terminal ║"
echo "║ soon into the env file. ║"
echo "║ ║"
echo "║ The webhook secret will start with whsec_... ║"
echo "║ ║"
echo "╚═════════════════════════════════════════════════════════════════════╝"
fi
echo "[INFO]: Starting Stripe webhook listener..."
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook

View File

@@ -13,7 +13,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
@@ -97,7 +97,7 @@ export const DocumentDuplicateDialog = ({
</div> </div>
) : ( ) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll"> <div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewer key={document?.id} documentData={documentData} /> <LazyPDFViewer key={document?.id} documentData={documentData} />
</div> </div>
)} )}

View File

@@ -30,20 +30,22 @@ import {
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = { export type TokenDeleteDialogProps = {
teamId?: number;
token: Pick<ApiToken, 'id' | 'name'>; token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void; onDelete?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
}; };
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) { export default function TokenDeleteDialog({
teamId,
token,
onDelete,
children,
}: TokenDeleteDialogProps) {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const team = useOptionalCurrentTeam();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const deleteMessage = _(msg`delete ${token.name}`); const deleteMessage = _(msg`delete ${token.name}`);
@@ -73,7 +75,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
try { try {
await deleteTokenMutation({ await deleteTokenMutation({
id: token.id, id: token.id,
teamId: team?.id, teamId,
}); });
toast({ toast({

View File

@@ -230,13 +230,14 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
/> />
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}> <Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create</Trans> <Trans>Create</Trans>
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</fieldset> </fieldset>
</form> </form>

View File

@@ -8,17 +8,11 @@ import { BrandingLogo } from '~/components/general/branding-logo';
export type EmbedAuthenticationRequiredProps = { export type EmbedAuthenticationRequiredProps = {
email?: string; email?: string;
returnTo: string; returnTo: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
}; };
export const EmbedAuthenticationRequired = ({ export const EmbedAuthenticationRequired = ({
email, email,
returnTo, returnTo,
// isGoogleSSOEnabled,
// isOIDCSSOEnabled,
// oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => { }: EmbedAuthenticationRequiredProps) => {
return ( return (
<div className="flex min-h-[100dvh] w-full items-center justify-center"> <div className="flex min-h-[100dvh] w-full items-center justify-center">
@@ -34,15 +28,7 @@ export const EmbedAuthenticationRequired = ({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<SignInForm <SignInForm className="mt-4" initialEmail={email} returnTo={returnTo} />
// Embed currently not supported.
// isGoogleSSOEnabled={isGoogleSSOEnabled}
// isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel}
className="mt-4"
initialEmail={email}
returnTo={returnTo}
/>
</div> </div>
</div> </div>
); );

View File

@@ -25,7 +25,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -47,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
fields: Field[]; fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
allowWhiteLabelling?: boolean; isPlatformOrEnterprise?: boolean;
}; };
export const EmbedDirectTemplateClientPage = ({ export const EmbedDirectTemplateClientPage = ({
@@ -58,7 +58,7 @@ export const EmbedDirectTemplateClientPage = ({
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
allowWhiteLabelling = false, isPlatformOrEnterprise = false,
}: EmbedDirectTemplateClientPageProps) => { }: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@@ -286,7 +286,7 @@ export const EmbedDirectTemplateClientPage = ({
document.documentElement.classList.add('dark-mode-disabled'); document.documentElement.classList.add('dark-mode-disabled');
} }
if (allowWhiteLabelling) { if (isPlatformOrEnterprise) {
injectCss({ injectCss({
css: data.css, css: data.css,
cssVars: data.cssVars, cssVars: data.cssVars,
@@ -338,7 +338,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">
<PDFViewer <LazyPDFViewer
documentData={documentData} documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
@@ -347,7 +347,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6"> <div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">

View File

@@ -1,33 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { XCircle } from 'lucide-react';
export const EmbedDocumentRejected = () => {
return (
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
</div>
</div>
);
};

View File

@@ -4,13 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client'; import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import { import { type DocumentData, type Field, FieldType, RecipientRole } from '@prisma/client';
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
@@ -24,7 +18,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -35,11 +29,9 @@ import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
import { EmbedClientLoading } from './embed-client-loading'; import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields'; import { EmbedDocumentFields } from './embed-document-fields';
import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = { export type EmbedSignDocumentClientPageProps = {
token: string; token: string;
@@ -50,7 +42,7 @@ export type EmbedSignDocumentClientPageProps = {
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean; isCompleted?: boolean;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
allowWhitelabelling?: boolean; isPlatformOrEnterprise?: boolean;
allRecipients?: RecipientWithFields[]; allRecipients?: RecipientWithFields[];
}; };
@@ -63,7 +55,7 @@ export const EmbedSignDocumentClientPage = ({
metadata, metadata,
isCompleted, isCompleted,
hidePoweredBy = false, hidePoweredBy = false,
allowWhitelabelling = false, isPlatformOrEnterprise = false,
allRecipients = [], allRecipients = [],
}: EmbedSignDocumentClientPageProps) => { }: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@@ -82,9 +74,6 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>( const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null, allRecipients.length > 0 ? allRecipients[0].id : null,
); );
@@ -93,8 +82,6 @@ export const EmbedSignDocumentClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId); const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT; const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
@@ -173,25 +160,6 @@ export const EmbedSignDocumentClientPage = ({
} }
}; };
const onDocumentRejected = (reason: string) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
reason,
},
},
'*',
);
}
setHasRejectedDocument(true);
};
useLayoutEffect(() => { useLayoutEffect(() => {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
@@ -205,13 +173,12 @@ export const EmbedSignDocumentClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring // Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates. // a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName); setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) { if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled'); document.documentElement.classList.add('dark-mode-disabled');
} }
if (allowWhitelabelling) { if (isPlatformOrEnterprise) {
injectCss({ injectCss({
css: data.css, css: data.css,
cssVars: data.cssVars, cssVars: data.cssVars,
@@ -240,10 +207,6 @@ export const EmbedSignDocumentClientPage = ({
} }
}, [hasFinishedInit, hasDocumentLoaded]); }, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (hasCompletedDocument) { if (hasCompletedDocument) {
return ( return (
<EmbedDocumentCompleted <EmbedDocumentCompleted
@@ -265,20 +228,10 @@ 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"> <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 />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && (
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
document={{ id: documentId }}
token={token}
onRejected={onDocumentRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="embed--DocumentViewer flex-1"> <div className="embed--DocumentViewer flex-1">
<PDFViewer <LazyPDFViewer
documentData={documentData} documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)} onDocumentLoad={() => setHasDocumentLoaded(true)}
/> />
@@ -287,7 +240,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6"> <div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
@@ -466,7 +419,7 @@ export const EmbedSignDocumentClientPage = ({
</Button> </Button>
) : ( ) : (
<Button <Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'} className="col-start-2"
disabled={ disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid) isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
} }

View File

@@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -42,7 +42,7 @@ export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
export const DisableAuthenticatorAppDialog = () => { export const DisableAuthenticatorAppDialog = () => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { refreshSession } = useSession(); const { revalidate } = useRevalidator();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp'); const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
@@ -92,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => {
onCloseTwoFactorDisableDialog(); onCloseTwoFactorDisableDialog();
}); });
await refreshSession(); await revalidate();
} catch (_err) { } catch (_err) {
toast({ toast({
title: _(msg`Unable to disable two-factor authentication`), title: _(msg`Unable to disable two-factor authentication`),

View File

@@ -5,12 +5,12 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file'; import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -48,7 +48,7 @@ export type EnableAuthenticatorAppDialogProps = {
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { refreshSession } = useSession(); const { revalidate } = useRevalidator();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
@@ -74,7 +74,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
try { try {
const data = await authClient.twoFactor.setup(); const data = await authClient.twoFactor.setup();
await refreshSession();
setSetup2FAData(data); setSetup2FAData(data);
} catch (err) { } catch (err) {
@@ -93,7 +92,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
try { try {
const data = await authClient.twoFactor.enable({ code: token }); const data = await authClient.twoFactor.enable({ code: token });
await refreshSession();
setRecoveryCodes(data.recoveryCodes); setRecoveryCodes(data.recoveryCodes);
onSuccess?.(); onSuccess?.();
@@ -141,6 +139,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null); setRecoveryCodes(null);
void revalidate();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -42,7 +42,7 @@ export type AvatarImageFormProps = {
}; };
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => { export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
const { user, refreshSession } = useSession(); const { user } = useSession();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { revalidate } = useRevalidator(); const { revalidate } = useRevalidator();
@@ -103,13 +103,13 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
teamId: team?.id, teamId: team?.id,
}); });
await refreshSession();
toast({ toast({
title: _(msg`Avatar Updated`), title: _(msg`Avatar Updated`),
description: _(msg`Your avatar has been updated successfully.`), description: _(msg`Your avatar has been updated successfully.`),
duration: 5000, duration: 5000,
}); });
void revalidate();
} catch (err) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);

View File

@@ -122,7 +122,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>New Password</Trans> <Trans>Password</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<PasswordInput autoComplete="new-password" {...field} /> <PasswordInput autoComplete="new-password" {...field} />

View File

@@ -3,6 +3,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -41,7 +42,8 @@ export type ProfileFormProps = {
export const ProfileForm = ({ className }: ProfileFormProps) => { export const ProfileForm = ({ className }: ProfileFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { user, refreshSession } = useSession(); const { user } = useSession();
const { revalidate } = useRevalidator();
const form = useForm<TProfileFormSchema>({ const form = useForm<TProfileFormSchema>({
values: { values: {
@@ -62,13 +64,13 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
signature, signature,
}); });
await refreshSession();
toast({ toast({
title: _(msg`Profile updated`), title: _(msg`Profile updated`),
description: _(msg`Your profile has been updated successfully.`), description: _(msg`Your profile has been updated successfully.`),
duration: 5000, duration: 5000,
}); });
await revalidate();
} catch (err) { } catch (err) {
toast({ toast({
title: _(msg`An unknown error occurred`), title: _(msg`An unknown error occurred`),

View File

@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { User } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '../general/user-profile-skeleton';
export const ZClaimPublicProfileFormSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
});
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
export type ClaimPublicProfileDialogFormProps = {
open: boolean;
onOpenChange?: (open: boolean) => void;
onClaimed?: () => void;
user: User;
};
export const ClaimPublicProfileDialogForm = ({
open,
onOpenChange,
onClaimed,
user,
}: ClaimPublicProfileDialogFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [claimed, setClaimed] = useState(false);
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TClaimPublicProfileFormSchema>({
values: {
url: user.url || '',
},
resolver: zodResolver(ZClaimPublicProfileFormSchema),
});
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
try {
await updatePublicProfile({
url,
});
setClaimed(true);
onClaimed?.();
} catch (err) {
const error = AppError.parseError(err);
if (error.code === 'PROFILE_URL_TAKEN') {
form.setError('url', {
type: 'manual',
message: _(msg`This username is already taken`),
});
} else if (error.code === 'PREMIUM_PROFILE_URL') {
form.setError('url', {
type: 'manual',
message: error.message,
});
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
toast({
title: 'An error occurred',
description: error.userMessage ?? error.message,
variant: 'destructive',
});
} else {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to save your details. Please try again later.`,
),
variant: 'destructive',
});
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent position="center" className="max-w-lg overflow-hidden">
{!claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">
Introducing public profiles!
</DialogTitle>
<DialogDescription className="text-center">
Reserve your Documenso public profile username
</DialogDescription>
</DialogHeader>
<img src={profileClaimTeaserImage} alt="profile claim teaser" />
<Form {...form}>
<form
className={cn(
'to-background -mt-32 flex w-full flex-col bg-gradient-to-b from-transparent to-15% pt-16 md:-mt-44',
)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="-mt-6 flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile username</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
<div className="mt-4 text-center">
<Button type="submit" loading={isSubmitting}>
Claim your username
</Button>
</div>
</form>
</Form>
</>
)}
{claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
<DialogDescription className="text-center">
We will let you know as soon as this features is launched
</DialogDescription>
</DialogHeader>
<UserProfileSkeleton className="mt-4" user={user} rows={1} />
<div className="to-background -mt-12 flex w-full flex-col items-center bg-gradient-to-b from-transparent to-15% px-4 pt-8 md:-mt-12">
<Button className="w-full" onClick={() => onOpenChange?.(false)}>
Can't wait!
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
@@ -38,8 +38,6 @@ import {
import { Switch } from '@documenso/ui/primitives/switch'; import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = { export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`, ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`, ONE_MONTH: msg`1 month`,
@@ -61,13 +59,14 @@ type NewlyCreatedToken = {
export type ApiTokenFormProps = { export type ApiTokenFormProps = {
className?: string; className?: string;
teamId?: number;
tokens?: Pick<ApiToken, 'id'>[]; tokens?: Pick<ApiToken, 'id'>[];
}; };
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard(); const [isTransitionPending, startTransition] = useTransition();
const team = useOptionalCurrentTeam(); const [, copy] = useCopyToClipboard();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@@ -114,7 +113,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => { const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try { try {
await createTokenMutation({ await createTokenMutation({
teamId: team?.id, teamId,
tokenName, tokenName,
expirationDate: noExpirationDate ? null : expirationDate, expirationDate: noExpirationDate ? null : expirationDate,
}); });
@@ -239,7 +238,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
type="submit" type="submit"
className="hidden md:inline-flex" className="hidden md:inline-flex"
disabled={!form.formState.isDirty} disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting} loading={form.formState.isSubmitting || isTransitionPending}
> >
<Trans>Create token</Trans> <Trans>Create token</Trans>
</Button> </Button>
@@ -248,7 +247,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
<Button <Button
type="submit" type="submit"
disabled={!form.formState.isDirty} disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting} loading={form.formState.isSubmitting || isTransitionPending}
> >
<Trans>Create token</Trans> <Trans>Create token</Trans>
</Button> </Button>

View File

@@ -1,138 +0,0 @@
import { useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
type Interval = keyof PriceIntervals;
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
day: msg`Daily`,
week: msg`Weekly`,
month: msg`Monthly`,
year: msg`Yearly`,
};
const MotionCard = motion(Card);
export type BillingPlansProps = {
prices: PriceIntervals;
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const isMounted = useIsMounted();
const [interval, setInterval] = useState<Interval>('month');
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
const { mutateAsync: createCheckoutSession } = trpc.profile.createCheckoutSession.useMutation();
const onSubscribeClick = async (priceId: string) => {
try {
setCheckoutSessionPriceId(priceId);
const url = await createCheckoutSession({ priceId });
if (!url) {
throw new Error('Unable to create session');
}
window.open(url);
} catch (_err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while trying to create a checkout session.`),
variant: 'destructive',
});
} finally {
setCheckoutSessionPriceId(null);
}
};
return (
<div>
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
<TabsList>
{INTERVALS.map(
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{_(FRIENDLY_INTERVALS[interval])}
</TabsTrigger>
),
)}
</TabsList>
</Tabs>
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence mode="wait">
{prices[interval].map((price) => (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-2 text-lg font-medium">
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
<span className="text-xs">per {interval}</span>
</div>
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
<li key={index} className="py-2">
{feature.name}
</li>
))}
</ul>
</div>
)}
<div className="flex-1" />
<Button
className="mt-4"
disabled={checkoutSessionPriceId !== null}
loading={checkoutSessionPriceId === price.id}
onClick={() => void onSubscribeClick(price.id)}
>
<Trans>Subscribe</Trans>
</Button>
</CardContent>
</MotionCard>
))}
</AnimatePresence>
</div>
</div>
);
};

View File

@@ -1,48 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type BillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
children?: React.ReactNode;
};
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: createBillingPortal, isPending } =
trpc.profile.createBillingPortal.useMutation({
onSuccess: (sessionUrl) => {
window.open(sessionUrl, '_blank');
},
onError: (err) => {
let description = _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
);
if (err.message === 'CUSTOMER_NOT_FOUND') {
description = _(
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
);
}
toast({
title: _(msg`Something went wrong`),
description,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Button {...buttonProps} onClick={async () => createBillingPortal()} loading={isPending}>
{children || <Trans>Manage Subscription</Trans>}
</Button>
);
};

View File

@@ -12,7 +12,7 @@ import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -136,7 +136,7 @@ export const DirectTemplatePageView = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer <LazyPDFViewer
key={template.id} key={template.id}
documentData={template.templateDocumentData} documentData={template.templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}

View File

@@ -45,12 +45,7 @@ export const DocumentSigningCheckboxField = ({
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const parsedFieldMeta = ZCheckboxFieldMeta.parse( const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
field.fieldMeta ?? {
type: 'checkbox',
values: [{ id: 1, checked: false, value: '' }],
},
);
const values = parsedFieldMeta.values?.map((item) => ({ const values = parsedFieldMeta.values?.map((item) => ({
...item, ...item,

View File

@@ -181,23 +181,6 @@ export const DocumentSigningFieldContainer = ({
</button> </button>
)} )}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children} {children}
</FieldRootContainer> </FieldRootContainer>
</div> </div>

View File

@@ -21,7 +21,7 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
@@ -140,7 +140,12 @@ export const DocumentSigningPageView = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer key={documentData.id} documentData={documentData} document={document} /> <LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -42,14 +42,9 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface DocumentSigningRejectDialogProps { export interface DocumentSigningRejectDialogProps {
document: Pick<Document, 'id'>; document: Pick<Document, 'id'>;
token: string; token: string;
onRejected?: (reason: string) => void | Promise<void>;
} }
export function DocumentSigningRejectDialog({ export function DocumentSigningRejectDialog({ document, token }: DocumentSigningRejectDialogProps) {
document,
token,
onRejected,
}: DocumentSigningRejectDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -80,13 +75,9 @@ export function DocumentSigningRejectDialog({
duration: 5000, duration: 5000,
}); });
setIsOpen(false);
if (onRejected) {
await onRejected(reason);
} else {
await navigate(`/sign/${token}/rejected`); await navigate(`/sign/${token}/rejected`);
}
setIsOpen(false);
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: 'Error',

View File

@@ -24,7 +24,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -132,6 +132,9 @@ export const DocumentEditForm = ({
}, },
}); });
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = { const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
settings: { settings: {
title: msg`General`, title: msg`General`,
@@ -312,6 +315,13 @@ export const DocumentEditForm = ({
} }
}; };
const onPasswordSubmit = async (password: string) => {
await setPasswordForDocument({
documentId: document.id,
password,
});
};
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
/** /**
@@ -330,10 +340,12 @@ export const DocumentEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer <LazyPDFViewer
key={document.documentData.id} key={document.documentData.id}
documentData={document.documentData} documentData={document.documentData}
document={document} document={document}
password={document.documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>

View File

@@ -85,7 +85,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`); void navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (err) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);

View File

@@ -38,7 +38,7 @@ export type GenericErrorLayoutProps = {
export const defaultErrorCodeMap: ErrorCodeMap = { export const defaultErrorCodeMap: ErrorCodeMap = {
404: { 404: {
subHeading: msg`404 not found`, subHeading: msg`404 Page not found`,
heading: msg`Oops! Something went wrong.`, heading: msg`Oops! Something went wrong.`,
message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`, message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`,
}, },
@@ -62,7 +62,7 @@ export const GenericErrorLayout = ({
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { subHeading, heading, message } = const { subHeading, heading, message } =
errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500]; errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500];
return ( return (
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center"> <div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">

View File

@@ -3,12 +3,10 @@ import { useCallback, useEffect } from 'react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
export const RefreshOnFocus = () => { export const RefreshOnFocus = () => {
const { revalidate, state } = useRevalidator(); const { revalidate } = useRevalidator();
const onFocus = useCallback(() => { const onFocus = useCallback(() => {
if (state === 'idle') {
void revalidate(); void revalidate();
}
}, [revalidate]); }, [revalidate]);
useEffect(() => { useEffect(() => {

View File

@@ -3,8 +3,8 @@ import React from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = { export type SettingsHeaderProps = {
title: string | React.ReactNode; title: string;
subtitle: string | React.ReactNode; subtitle: string;
hideDivider?: boolean; hideDivider?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;

View File

@@ -15,7 +15,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper'; import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@@ -236,7 +236,7 @@ export const TemplateEditForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer <LazyPDFViewer
key={templateDocumentData.id} key={templateDocumentData.id}
documentData={templateDocumentData} documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)} onDocumentLoad={() => setIsDocumentPdfLoaded(true)}

View File

@@ -1,96 +1,43 @@
import { useEffect, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { WebhookTriggerEvents } from '@prisma/client'; import { WebhookTriggerEvents } from '@prisma/client';
import { Check, ChevronsUpDown } from 'lucide-react';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name'; import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { cn } from '@documenso/ui/lib/utils'; import { MultipleSelector } from '@documenso/ui/primitives/multiselect';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { truncateTitle } from '~/utils/truncate-title';
type WebhookMultiSelectComboboxProps = { type WebhookMultiSelectComboboxProps = {
listValues: string[]; listValues: string[];
onChange: (_values: string[]) => void; onChange: (_values: string[]) => void;
}; };
const triggerEvents = Object.values(WebhookTriggerEvents).map((value) => ({
value,
label: toFriendlyWebhookEventName(value),
}));
export const WebhookMultiSelectCombobox = ({ export const WebhookMultiSelectCombobox = ({
listValues, listValues,
onChange, onChange,
}: WebhookMultiSelectComboboxProps) => { }: WebhookMultiSelectComboboxProps) => {
const [isOpen, setIsOpen] = useState(false); const handleOnChange = (options: { value: string; label: string }[]) => {
const [selectedValues, setSelectedValues] = useState<string[]>([]); onChange(options.map((option) => option.value));
const triggerEvents = Object.values(WebhookTriggerEvents);
useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setIsOpen(false);
}; };
const mappedValues = listValues.map((value) => ({
value,
label: toFriendlyWebhookEventName(value),
}));
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}> <MultipleSelector
<PopoverTrigger asChild> commandProps={{
<Button label: 'Select triggers',
variant="outline" }}
role="combobox" defaultOptions={triggerEvents}
aria-expanded={isOpen} value={mappedValues}
className="w-[200px] justify-between" onChange={handleOnChange}
> placeholder="Select triggers"
<Plural value={selectedValues.length} zero="Select values" other="# selected..." /> hideClearAllButton
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> hidePlaceholderWhenSelected
</Button> emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
</PopoverTrigger>
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
<Command>
<CommandInput
placeholder={truncateTitle(
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
15,
)}
/> />
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>
<CommandGroup>
{allEvents.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{toFriendlyWebhookEventName(value)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
); );
}; };

View File

@@ -1,31 +1,21 @@
import { StrictMode, startTransition, useEffect } from 'react'; import { StrictMode, startTransition } from 'react';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale'; import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react'; import { I18nProvider } from '@lingui/react';
import posthog from 'posthog-js';
import { hydrateRoot } from 'react-dom/client'; import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom'; import { HydratedRouter } from 'react-router/dom';
import { Theme, ThemeProvider } from 'remix-themes';
import { match } from 'ts-pattern';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { dynamicActivate } from '@documenso/lib/utils/i18n';
function PosthogInit() {
const postHogConfig = extractPostHogConfig();
useEffect(() => {
if (postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
}
}, []);
return null;
}
async function main() { async function main() {
const theme = match(document.documentElement.getAttribute('data-theme'))
.with('dark', () => Theme.DARK)
.with('light', () => Theme.LIGHT)
.otherwise(() => null);
const locale = detect(fromHtmlTag('lang')) || 'en'; const locale = detect(fromHtmlTag('lang')) || 'en';
await dynamicActivate(locale); await dynamicActivate(locale);
@@ -35,10 +25,10 @@ async function main() {
document, document,
<StrictMode> <StrictMode>
<I18nProvider i18n={i18n}> <I18nProvider i18n={i18n}>
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<HydratedRouter /> <HydratedRouter />
</ThemeProvider>
</I18nProvider> </I18nProvider>
<PosthogInit />
</StrictMode>, </StrictMode>,
); );
}); });

View File

@@ -7,11 +7,13 @@ import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router'; import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router'; import { ServerRouter } from 'react-router';
import { ThemeProvider } from 'remix-themes';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n'; import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n';
import { langCookie } from './storage/lang-cookie.server'; import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
export const streamTimeout = 5_000; export const streamTimeout = 5_000;
@@ -30,6 +32,10 @@ export default async function handleRequest(
await dynamicActivate(language); await dynamicActivate(language);
const { getTheme } = await themeSessionResolver(request);
const theme = getTheme();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false;
const userAgent = request.headers.get('user-agent'); const userAgent = request.headers.get('user-agent');
@@ -41,7 +47,9 @@ export default async function handleRequest(
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<I18nProvider i18n={i18n}> <I18nProvider i18n={i18n}>
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<ServerRouter context={routerContext} url={request.url} /> <ServerRouter context={routerContext} url={request.url} />
</ThemeProvider>
</I18nProvider>, </I18nProvider>,
{ {
[readyOption]() { [readyOption]() {

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import posthog from 'posthog-js';
import { useLocation, useSearchParams } from 'react-router';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function PostHogPageview() {
const postHogConfig = extractPostHogConfig();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
// const { sessionData } = useOptionalSession();
// const user = sessionData?.user;
if (typeof window !== 'undefined' && postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
disable_session_recording: true,
// loaded: () => {
// if (user) {
// posthog.identify(user.email ?? user.id.toString());
// } else {
// posthog.reset();
// }
// },
custom_campaign_params: ['src'],
});
}
useEffect(() => {
if (!postHogConfig || !pathname) {
return;
}
let url = window.origin + pathname;
if (searchParams && searchParams.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture('$pageview', {
$current_url: url,
});
}, [pathname, searchParams, postHogConfig]);
return null;
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { Suspense, useEffect } from 'react';
import Plausible from 'plausible-tracker'; import Plausible from 'plausible-tracker';
import { import {
@@ -12,7 +12,7 @@ import {
useLoaderData, useLoaderData,
useLocation, useLocation,
} from 'react-router'; } from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; import { PreventFlashOnWrongTheme, useTheme } from 'remix-themes';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { SessionProvider } from '@documenso/lib/client-only/providers/session'; import { SessionProvider } from '@documenso/lib/client-only/providers/session';
@@ -28,6 +28,7 @@ import type { Route } from './+types/root';
import stylesheet from './app.css?url'; import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout'; import { GenericErrorLayout } from './components/general/generic-error-layout';
import { RefreshOnFocus } from './components/general/refresh-on-focus'; import { RefreshOnFocus } from './components/general/refresh-on-focus';
import { PostHogPageview } from './providers/posthog';
import { langCookie } from './storage/lang-cookie.server'; import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server'; import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta'; import { appMetaTags } from './utils/meta';
@@ -105,7 +106,9 @@ export async function loader({ request }: Route.LoaderArgs) {
} }
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const { theme } = useLoaderData<typeof loader>() || {}; const { publicEnv, lang, session, ...data } = useLoaderData<typeof loader>() || {};
const [theme] = useTheme();
const location = useLocation(); const location = useLocation();
@@ -115,18 +118,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
}, [location.pathname]); }, [location.pathname]);
return (
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<LayoutContent>{children}</LayoutContent>
</ThemeProvider>
);
}
export function LayoutContent({ children }: { children: React.ReactNode }) {
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
const [theme] = useTheme();
return ( return (
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}> <html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
<head> <head>
@@ -142,6 +133,10 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} /> <PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
<Suspense>
<PostHogPageview />
</Suspense>
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */} {/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script>0</script> <script>0</script>
</head> </head>
@@ -176,11 +171,9 @@ export default function App() {
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error); console.error('[RootErrorBoundary]', error);
}
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return <GenericErrorLayout errorCode={errorCode} />; return <GenericErrorLayout errorCode={errorCode} />;
} }

View File

@@ -20,13 +20,13 @@ import type { Route } from './+types/_layout';
*/ */
export const shouldRevalidate = () => false; export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) { export const loader = async ({ request }: Route.LoaderArgs) => {
const requestHeaders = Object.fromEntries(request.headers.entries()); const requestHeaders = Object.fromEntries(request.headers.entries());
const session = await getOptionalSession(request); const session = await getOptionalSession(request);
if (!session.isAuthenticated) { if (!session.isAuthenticated) {
throw redirect('/signin'); return redirect('/signin');
} }
const [limits, banner] = await Promise.all([ const [limits, banner] = await Promise.all([
@@ -40,7 +40,7 @@ export async function loader({ request }: Route.LoaderArgs) {
banner, banner,
limits, limits,
}; };
} };
export default function Layout({ loaderData }: Route.ComponentProps) { export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams } = useSession(); const { user, teams } = useSession();

View File

@@ -16,7 +16,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet'; import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button'; import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
@@ -33,7 +33,7 @@ import {
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/documents.$id._index'; import type { Route } from './+types/$id._index';
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request); const { user } = await getSession(request);
@@ -196,7 +196,7 @@ export default function DocumentPage() {
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer document={document} key={documentData.id} documentData={documentData} /> <LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -16,7 +16,7 @@ import { DocumentStatus } from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/documents.$id.edit'; import type { Route } from './+types/$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request); const { user } = await getSession(request);

View File

@@ -22,7 +22,7 @@ import {
} from '~/components/general/document/document-status'; } from '~/components/general/document/document-status';
import { DocumentLogsTable } from '~/components/tables/document-logs-table'; import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/documents.$id.logs'; import type { Route } from './+types/$id.logs';
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request); const { user } = await getSession(request);

View File

@@ -1,157 +0,0 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { BillingPlans } from '~/components/general/billing-plans';
import { BillingPortalButton } from '~/components/general/billing-portal-button';
import { appMetaTags } from '~/utils/meta';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/billing';
export function meta() {
return appMetaTags('Billing');
}
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// Redirect if subscriptions are not enabled.
if (!IS_BILLING_ENABLED()) {
throw redirect('/settings/profile');
}
if (!user.customerId) {
await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
getPrimaryAccountPlanPrices(),
]);
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
primaryAccountPlanPriceIds.includes(priceId),
);
const subscription =
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
primaryAccountPlanSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null,
);
}
const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
return superLoaderJson({
prices,
subscription,
subscriptionProductName: subscriptionProduct?.name,
isMissingOrInactiveOrFreePlan,
});
}
export default function TeamsSettingBillingPage() {
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
useSuperLoaderData<typeof loader>();
const { i18n } = useLingui();
return (
<div>
<div className="flex flex-row items-end justify-between">
<div>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
<Trans>
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
)}
{/* Todo: Translation */}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProductName ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProductName}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
) : (
<span>
automatically renew on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
)}
</span>
)}
</p>
))
.with('PAST_DUE', () => (
<p>
<Trans>
Your current plan is past due. Please update your payment information.
</Trans>
</p>
))
.otherwise(() => null)}
</div>
</div>
{isMissingOrInactiveOrFreePlan && (
<BillingPortalButton>
<Trans>Manage billing</Trans>
</BillingPortalButton>
)}
</div>
<hr className="my-4" />
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
</div>
);
}

View File

@@ -21,10 +21,10 @@ import { ManagePublicTemplateDialog } from '~/components/dialogs/public-profile-
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form'; import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
import { PublicProfileForm } from '~/components/forms/public-profile-form'; import { PublicProfileForm } from '~/components/forms/public-profile-form';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsPublicProfileTemplatesTable } from '~/components/tables/settings-public-profile-templates-table';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import type { Route } from './+types/public-profile'; import { SettingsPublicProfileTemplatesTable } from '../../../../components/tables/settings-public-profile-templates-table';
import type { Route } from './+types/index';
type DirectTemplate = FindTemplateRow & { type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>; directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
@@ -60,7 +60,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { user, refreshSession } = useSession(); const { user } = useSession();
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled); const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
@@ -96,9 +96,6 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
}); });
} else { } else {
await updateUserProfile(data); await updateUserProfile(data);
// Need to refresh session because we're editing the user's profile.
await refreshSession();
} }
if (data.enabled === undefined && !isPublicProfileVisible) { if (data.enabled === undefined && !isPublicProfileVisible) {

View File

@@ -16,7 +16,7 @@ import { PasswordForm } from '~/components/forms/password';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/security._index'; import type { Route } from './+types';
export function meta() { export function meta() {
return appMetaTags('Security'); return appMetaTags('Security');

View File

@@ -7,10 +7,11 @@ import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animat
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog'; import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
import { TeamInvitations } from '~/components/general/teams/team-invitations';
import { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table'; import { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table';
import { TeamEmailUsage } from './team-email-usage';
import { TeamInvitations } from './team-invitations';
export default function TeamsSettingsPage() { export default function TeamsSettingsPage() {
const { _ } = useLingui(); const { _ } = useLingui();

View File

@@ -0,0 +1,91 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
return (
<div>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
On this page, you can create new API tokens and manage the existing ones. <br />
Also see our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>
.
</Trans>
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,116 +0,0 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
const team = useOptionalCurrentTeam();
return (
<div>
<SettingsHeader
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
On this page, you can create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>{' '}
for more information.
</Trans>
}
/>
{team && team?.currentTeamMember.role !== TeamMemberRole.ADMIN ? (
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="warning"
>
<div>
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>You need to be an admin to manage API tokens.</Trans>
</AlertDescription>
</div>
</Alert>
) : (
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>
Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}
</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>
Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}
</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.$id._index'; import DocumentPage, { loader } from '~/routes/_authenticated+/documents+/$id._index';
export { loader }; export { loader };

View File

@@ -1,4 +1,4 @@
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.$id.edit'; import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents+/$id.edit';
export { loader }; export { loader };

View File

@@ -1,4 +1,4 @@
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.$id.logs'; import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents+/$id.logs';
export { loader }; export { loader };

View File

@@ -1,4 +1,4 @@
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents._index'; import DocumentsPage, { meta } from '~/routes/_authenticated+/documents+/_index';
export { meta }; export { meta };

View File

@@ -20,7 +20,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown'; import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status'; import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status';
import type { Route } from './+types/settings._index'; import type { Route } from './+types/_index';
export async function loader({ request, params }: Route.LoaderArgs) { export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request); const { user } = await getSession(request);

View File

@@ -9,7 +9,7 @@ import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile'; import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings._layout'; import type { Route } from './+types/_layout';
export function meta() { export function meta() {
return appMetaTags('Team Settings'); return appMetaTags('Team Settings');

View File

@@ -14,9 +14,8 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button'; import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table'; import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/settings.billing'; import type { Route } from './+types/billing';
export async function loader({ request, params }: Route.LoaderArgs) { export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request); const session = await getSession(request);
@@ -32,16 +31,16 @@ export async function loader({ request, params }: Route.LoaderArgs) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId); teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
} }
return superLoaderJson({ return {
team, team,
teamSubscription, teamSubscription,
}); };
} }
export default function TeamsSettingBillingPage() { export default function TeamsSettingBillingPage({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui(); const { _ } = useLingui();
const { team, teamSubscription } = useSuperLoaderData<typeof loader>(); const { team, teamSubscription } = loaderData;
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role); const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);

View File

@@ -8,7 +8,7 @@ import { TeamBrandingPreferencesForm } from '~/components/forms/team-branding-pr
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form'; import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/settings.preferences'; import type { Route } from './+types/preferences';
export async function loader({ request, params }: Route.LoaderArgs) { export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request); const { user } = await getSession(request);

View File

@@ -2,9 +2,9 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile'; import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile'; import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
import type { Route } from './+types/settings.public-profile'; import type { Route } from './+types/public-profile';
// Todo: This can be optimized. // Todo: This can be optimized.
export async function loader({ request, params }: Route.LoaderArgs) { export async function loader({ request, params }: Route.LoaderArgs) {

View File

@@ -0,0 +1,126 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/tokens';
// Todo: This can be optimized.
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);
return {
user,
team,
tokens,
};
}
export default function ApiTokensPage({ loaderData }: Route.ComponentProps) {
const { i18n } = useLingui();
const { team, tokens } = loaderData;
if (!tokens) {
return (
<div>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Something went wrong.</Trans>
</p>
</div>
);
}
return (
<div>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
On this page, you can create new API tokens and manage the existing ones. <br />
You can view our swagger docs{' '}
<a
className="text-primary underline"
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
target="_blank"
>
here
</a>
</Trans>
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" teamId={team.id} tokens={tokens} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token} teamId={team.id}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -28,7 +28,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox'; import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/settings.webhooks.$id'; import type { Route } from './+types/webhooks.$id';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true }); const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });

View File

@@ -1,3 +0,0 @@
import ApiTokensPage from '~/routes/_authenticated+/settings+/tokens';
export default ApiTokensPage;

View File

@@ -1,4 +1,4 @@
import TemplatePage, { loader } from '~/routes/_authenticated+/templates.$id._index'; import TemplatePage, { loader } from '~/routes/_authenticated+/templates+/$id._index';
export { loader }; export { loader };

View File

@@ -1,4 +1,4 @@
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates.$id.edit'; import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates+/$id.edit';
export { loader }; export { loader };

View File

@@ -1,4 +1,4 @@
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates._index'; import TemplatesPage, { meta } from '~/routes/_authenticated+/templates+/_index';
export { meta }; export { meta };

View File

@@ -9,7 +9,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog'; import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper'; import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
@@ -24,7 +24,7 @@ import { TemplateType } from '~/components/general/template/template-type';
import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown'; import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/templates.$id._index'; import type { Route } from './+types/$id._index';
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request); const { user } = await getSession(request);
@@ -144,7 +144,11 @@ export default function TemplatePage() {
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<PDFViewer document={template} key={template.id} documentData={templateDocumentData} /> <LazyPDFViewer
document={template}
key={template.id}
documentData={templateDocumentData}
/>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -13,8 +13,8 @@ import { TemplateEditForm } from '~/components/general/template/template-edit-fo
import { TemplateType } from '~/components/general/template/template-type'; import { TemplateType } from '~/components/general/template/template-type';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { TemplateDirectLinkDialogWrapper } from '../../components/dialogs/template-direct-link-dialog-wrapper'; import { TemplateDirectLinkDialogWrapper } from '../../../components/dialogs/template-direct-link-dialog-wrapper';
import type { Route } from './+types/templates.$id.edit'; import type { Route } from './+types/$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request); const { user } = await getSession(request);

View File

@@ -1,48 +1,15 @@
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ZTeamUrlSchema } from '@documenso/trpc/server/team-router/schema';
import type { Route } from './+types/_index'; import type { Route } from './+types/_index';
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const session = await getOptionalSession(request); const { isAuthenticated } = await getOptionalSession(request);
if (session.isAuthenticated) { if (isAuthenticated) {
const teamUrlCookie = extractCookieFromHeaders('preferred-team-url', request.headers);
const referrer = request.headers.get('referer');
let isReferrerFromTeamUrl = false;
if (referrer) {
const referrerUrl = new URL(referrer);
if (referrerUrl.pathname.startsWith('/t/')) {
isReferrerFromTeamUrl = true;
}
}
const preferredTeamUrl =
teamUrlCookie && ZTeamUrlSchema.safeParse(teamUrlCookie).success ? teamUrlCookie : undefined;
// Early return for no preferred team.
if (!preferredTeamUrl || isReferrerFromTeamUrl) {
throw redirect('/documents'); throw redirect('/documents');
} }
const teams = await getTeams({ userId: session.user.id });
const currentTeam = teams.find((team) => team.url === preferredTeamUrl);
if (!currentTeam) {
throw redirect('/documents');
}
throw redirect(formatDocumentsPath(currentTeam.url));
}
throw redirect('/signin'); throw redirect('/signin');
} }

View File

@@ -9,7 +9,7 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { getTranslations } from '@documenso/lib/utils/i18n'; import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
@@ -49,13 +49,10 @@ export async function loader({ request }: Route.LoaderArgs) {
perPage: 100_000, perPage: 100_000,
}); });
const messages = await getTranslations(documentLanguage);
return { return {
auditLogs, auditLogs,
document, document,
documentLanguage, documentLanguage,
messages,
}; };
} }
@@ -64,15 +61,16 @@ export async function loader({ request }: Route.LoaderArgs) {
* *
* Cannot use dynamicActivate by itself to translate this specific page and all * Cannot use dynamicActivate by itself to translate this specific page and all
* children components because `not-found.tsx` page runs and overrides the i18n. * children components because `not-found.tsx` page runs and overrides the i18n.
*
* Update: Maybe <Trans> tags work now after RR7 migration.
*/ */
export default function AuditLog({ loaderData }: Route.ComponentProps) { export default function AuditLog({ loaderData }: Route.ComponentProps) {
const { auditLogs, document, documentLanguage, messages } = loaderData; const { auditLogs, document, documentLanguage } = loaderData;
const { i18n, _ } = useLingui(); const { i18n } = useLingui();
i18n.loadAndActivate({ locale: documentLanguage, messages }); // Todo
void dynamicActivate(documentLanguage);
const { _ } = useLingui();
return ( return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md"> <div className="print-provider pointer-events-none mx-auto max-w-screen-md">

View File

@@ -16,7 +16,6 @@ import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs'; import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getTranslations } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
Table, Table,
@@ -65,13 +64,10 @@ export async function loader({ request }: Route.LoaderArgs) {
id: documentId, id: documentId,
}); });
const messages = await getTranslations(documentLanguage);
return { return {
document, document,
documentLanguage, documentLanguage,
auditLogs, auditLogs,
messages,
}; };
} }
@@ -81,15 +77,16 @@ export async function loader({ request }: Route.LoaderArgs) {
* *
* Cannot use dynamicActivate by itself to translate this specific page and all * Cannot use dynamicActivate by itself to translate this specific page and all
* children components because `not-found.tsx` page runs and overrides the i18n. * children components because `not-found.tsx` page runs and overrides the i18n.
*
* Update: Maybe <Trans> tags work now after RR7 migration.
*/ */
export default function SigningCertificate({ loaderData }: Route.ComponentProps) { export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, documentLanguage, auditLogs, messages } = loaderData; const { document, documentLanguage, auditLogs } = loaderData;
const { i18n, _ } = useLingui(); const { i18n } = useLingui();
i18n.loadAndActivate({ locale: documentLanguage, messages }); const { _ } = useLingui();
// Todo
// dynamicActivate(i18n, documentLanguage);
const isOwner = (email: string) => { const isOwner = (email: string) => {
return email.toLowerCase() === document.user.email.toLowerCase(); return email.toLowerCase() === document.user.email.toLowerCase();

View File

@@ -7,7 +7,7 @@ import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router'; import { Link, Outlet } from 'react-router';
import LogoIcon from '@documenso/assets/logo_icon.png'; import LogoIcon from '@documenso/assets/logo_icon.png';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -21,7 +21,7 @@ export function meta() {
} }
export default function PublicProfileLayout() { export default function PublicProfileLayout() {
const { sessionData } = useOptionalSession(); const session = useSession();
const [scrollY, setScrollY] = useState(0); const [scrollY, setScrollY] = useState(0);
@@ -37,8 +37,8 @@ export default function PublicProfileLayout() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{sessionData ? ( {session ? (
<AuthenticatedHeader user={sessionData.user} teams={sessionData.teams} /> <AuthenticatedHeader user={session.user} teams={session.teams} />
) : ( ) : (
<header <header
className={cn( className={cn(

View File

@@ -44,6 +44,7 @@ export async function loader({ params }: Route.LoaderArgs) {
profileUrl, profileUrl,
}).catch(() => null); }).catch(() => null);
// Todo: Test
if (!publicProfile || !publicProfile.profile.enabled) { if (!publicProfile || !publicProfile.profile.enabled) {
throw new Response('Not Found', { status: 404 }); throw new Response('Not Found', { status: 404 });
} }

View File

@@ -6,7 +6,6 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header'; import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
/** /**
* A layout to handle scenarios where the user is a recipient of a given resource * A layout to handle scenarios where the user is a recipient of a given resource
@@ -30,18 +29,35 @@ export default function RecipientLayout() {
); );
} }
// Todo: Use generic error boundary.
export function ErrorBoundary() { export function ErrorBoundary() {
return ( return (
<GenericErrorLayout <div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
secondaryButton={null} <div>
primaryButton={ <p className="text-muted-foreground font-semibold">
<Trans>404 Not found</Trans>
</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
<Trans>Oops! Something went wrong.</Trans>
</h1>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
The resource you are looking for may have been disabled, deleted or may have never
existed.
</Trans>
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button asChild className="w-32"> <Button asChild className="w-32">
<Link to="/"> <Link to="/">
<ChevronLeft className="mr-2 h-4 w-4" /> <ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans> <Trans>Go Back</Trans>
</Link> </Link>
</Button> </Button>
} </div>
/> </div>
</div>
); );
} }

View File

@@ -19,30 +19,16 @@ const posthogProxy = async (request: Request) => {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.set('host', hostname); headers.set('host', hostname);
const fetchOptions: RequestInit = { const response = await fetch(newUrl, {
method: request.method, method: request.method,
headers, headers,
redirect: 'follow', body: request.body,
}; });
if (!['GET', 'HEAD'].includes(request.method)) {
fetchOptions.body = request.body;
// @ts-expect-error - It should exist
fetchOptions.duplex = 'half';
}
const response = await fetch(newUrl, fetchOptions);
const responseHeaders = new Headers(response.headers);
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
responseHeaders.delete('transfer-encoding');
responseHeaders.delete('cookie');
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: responseHeaders, headers: response.headers,
}); });
}; };

View File

@@ -1,10 +1,11 @@
// Todo: Test, used AI to migrate this component from NextJS to Remix.
import satori from 'satori'; import satori from 'satori';
import sharp from 'sharp'; import sharp from 'sharp';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug';
import type { ShareHandlerAPIResponse } from '../api+/share';
import type { Route } from './+types/share.$slug.opengraph'; import type { Route } from './+types/share.$slug.opengraph';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -36,9 +37,9 @@ export const loader = async ({ params }: Route.LoaderArgs) => {
), ),
]); ]);
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ const recipientOrSender: ShareHandlerAPIResponse = await fetch(
slug, new URL(`/api/share?slug=${slug}`, baseUrl),
}); ).then(async (res) => res.json());
if ('error' in recipientOrSender) { if ('error' in recipientOrSender) {
return Response.json({ error: 'Not found' }, { status: 404 }); return Response.json({ error: 'Not found' }, { status: 404 });

View File

@@ -1,44 +1,45 @@
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { Route } from './+types/share.$slug'; import type { Route } from './+types/share.$slug';
// Todo: Test meta.
export function meta({ params: { slug } }: Route.MetaArgs) { export function meta({ params: { slug } }: Route.MetaArgs) {
return [ return [
{ title: 'Documenso - Share' }, { title: 'Documenso - Share' },
{ description: 'I just signed a document in style with Documenso!' }, { description: 'I just signed a document in style with Documenso!' },
{ {
property: 'og:title', property: 'og:title',
content: 'Documenso - Join the open source signing revolution', title: 'Documenso - Join the open source signing revolution',
}, },
{ {
property: 'og:description', property: 'og:description',
content: 'I just signed with Documenso!', description: 'I just signed with Documenso!',
}, },
{ {
property: 'og:type', property: 'og:type',
content: 'website', type: 'website',
}, },
{ {
property: 'og:image', property: 'og:images',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`, images: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`,
}, },
{ {
name: 'twitter:site', name: 'twitter:site',
content: '@documenso', site: '@documenso',
}, },
{ {
name: 'twitter:card', name: 'twitter:card',
content: 'summary_large_image', card: 'summary_large_image',
}, },
{ {
name: 'twitter:image', name: 'twitter:images',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`, images: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`,
}, },
{ {
name: 'twitter:description', name: 'twitter:description',
content: 'I just signed with Documenso!', description: 'I just signed with Documenso!',
}, },
]; ];
} }
@@ -50,10 +51,5 @@ export const loader = ({ request }: Route.LoaderArgs) => {
return null; return null;
} }
// Is hardcoded because this whole meta is hardcoded anyway for Documenso. throw redirect(NEXT_PUBLIC_MARKETING_URL());
throw redirect('https://documenso.com');
}; };
export default function SharePage() {
return <div></div>;
}

View File

@@ -8,7 +8,6 @@ import { Link, redirect, useNavigate } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email'; import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -30,7 +29,6 @@ export const loader = ({ params }: Route.LoaderArgs) => {
export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) { export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData; const { token } = loaderData;
const { refreshSession } = useOptionalSession();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -46,8 +44,6 @@ export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
token, token,
}); });
await refreshSession();
setState(response.state); setState(response.state);
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -0,0 +1,27 @@
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
import type { Route } from './+types/share';
export type ShareHandlerAPIResponse =
| Awaited<ReturnType<typeof getRecipientOrSenderByShareLinkSlug>>
| { error: string };
// Todo: Test
export async function loader({ request }: Route.LoaderArgs) {
try {
const url = new URL(request.url);
const slug = url.searchParams.get('slug');
if (typeof slug !== 'string') {
throw new Error('Invalid slug');
}
const data = await getRecipientOrSenderByShareLinkSlug({
slug,
});
return Response.json(data);
} catch (error) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
}

View File

@@ -1,7 +1,11 @@
import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler'; import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
// Todo
// export const config = {
// api: { bodyParser: false },
// };
import type { Route } from './+types/webhook.trigger'; import type { Route } from './+types/webhook.trigger';
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
return await stripeWebhookHandler(request); return stripeWebhookHandler(request);
} }

View File

@@ -2,6 +2,16 @@ import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trig
import type { Route } from './+types/webhook.trigger'; import type { Route } from './+types/webhook.trigger';
// Todo
// export const config = {
// maxDuration: 300,
// api: {
// bodyParser: {
// sizeLimit: '50mb',
// },
// },
// };
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
return handlerTriggerWebhooks(request); return handlerTriggerWebhooks(request);
} }

View File

@@ -1,18 +1,12 @@
import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router'; import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required'; import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn'; import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
import { EmbedPaywall } from '~/components/embed/embed-paywall'; import { EmbedPaywall } from '~/components/embed/embed-paywall';
import type { Route } from './+types/_layout'; import type { Route } from './+types/_layout';
// Todo: (RR7) Test // Todo: Test
export function headers({ loaderHeaders }: Route.HeadersArgs) { export function headers({ loaderHeaders }: Route.HeadersArgs) {
const origin = loaderHeaders.get('Origin') ?? '*'; const origin = loaderHeaders.get('Origin') ?? '*';
@@ -26,38 +20,17 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
}; };
} }
export function loader() {
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
return {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
};
}
export default function Layout() { export default function Layout() {
return <Outlet />; return <Outlet />;
} }
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) { export function ErrorBoundary() {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const error = useRouteError(); const error = useRouteError();
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') { if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return ( return (
<EmbedAuthenticationRequired <EmbedAuthenticationRequired email={error.data.email} returnTo={error.data.returnTo} />
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
email={error.data.email}
returnTo={error.data.returnTo}
/>
); );
} }

View File

@@ -1,8 +1,7 @@
import { data } from 'react-router'; import { data } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@@ -50,22 +49,18 @@ export async function loader({ params, request }: Route.LoaderArgs) {
); );
} }
const { user } = await getOptionalSession(request); const { user } = await getSession(request);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions, documentAuth: template.authOptions,
}); });
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([ const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
isDocumentPlatform(template), isDocumentPlatform(template),
isUserEnterprise({ isUserEnterprise({
userId: template.userId, userId: template.userId,
teamId: template.teamId ?? undefined, teamId: template.teamId ?? undefined,
}), }),
isUserCommunityPlan({
userId: template.userId,
teamId: template.teamId ?? undefined,
}),
]); ]);
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth)
@@ -113,7 +108,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
hidePoweredBy, hidePoweredBy,
isPlatformDocument, isPlatformDocument,
isEnterpriseDocument, isEnterpriseDocument,
isCommunityPlan,
}); });
} }
@@ -127,7 +121,6 @@ export default function EmbedDirectTemplatePage() {
hidePoweredBy, hidePoweredBy,
isPlatformDocument, isPlatformDocument,
isEnterpriseDocument, isEnterpriseDocument,
isCommunityPlan,
} = useSuperLoaderData<typeof loader>(); } = useSuperLoaderData<typeof loader>();
return ( return (
@@ -145,10 +138,8 @@ export default function EmbedDirectTemplatePage() {
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={template.templateMeta} metadata={template.templateMeta}
hidePoweredBy={ hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
}
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
/> />
</DocumentSigningRecipientProvider> </DocumentSigningRecipientProvider>
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>

View File

@@ -2,8 +2,7 @@ import { DocumentStatus, RecipientRole } from '@prisma/client';
import { data } from 'react-router'; import { data } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@@ -30,7 +29,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const token = params.url; const token = params.url;
const { user } = await getOptionalSession(request); const { user } = await getSession(request);
const [document, fields, recipient] = await Promise.all([ const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
@@ -62,16 +61,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
); );
} }
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([ const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
isDocumentPlatform(document), isDocumentPlatform(document),
isUserEnterprise({ isUserEnterprise({
userId: document.userId, userId: document.userId,
teamId: document.teamId ?? undefined, teamId: document.teamId ?? undefined,
}), }),
isUserCommunityPlan({
userId: document.userId,
teamId: document.teamId ?? undefined,
}),
]); ]);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -132,7 +127,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
hidePoweredBy, hidePoweredBy,
isPlatformDocument, isPlatformDocument,
isEnterpriseDocument, isEnterpriseDocument,
isCommunityPlan,
}); });
} }
@@ -147,7 +141,6 @@ export default function EmbedSignDocumentPage() {
hidePoweredBy, hidePoweredBy,
isPlatformDocument, isPlatformDocument,
isEnterpriseDocument, isEnterpriseDocument,
isCommunityPlan,
} = useSuperLoaderData<typeof loader>(); } = useSuperLoaderData<typeof loader>();
return ( return (
@@ -169,10 +162,8 @@ export default function EmbedSignDocumentPage() {
fields={fields} fields={fields}
metadata={document.documentMeta} metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED} isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={ hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
}
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
allRecipients={allRecipients} allRecipients={allRecipients}
/> />
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>

View File

@@ -13,5 +13,4 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
.optional() .optional()
.transform((value) => value || undefined), .transform((value) => value || undefined),
lockName: z.boolean().optional().default(false), lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(),
}); });

Some files were not shown because too many files have changed in this diff Show More