Merge branch 'main' into chore/add-manual-translations

This commit is contained in:
David Nguyen
2026-08-14 10:58:56 +10:00
committed by GitHub
44 changed files with 1276 additions and 229 deletions
@@ -1,7 +1,7 @@
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n'; import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
@@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
))} ))}
<Link <Link
to="https://documen.so/enterprise-cta" to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
target="_blank" target="_blank"
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4" className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
> >
+2 -1
View File
@@ -1,5 +1,6 @@
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes'; import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { formatPath } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
@@ -44,7 +45,7 @@ const handleFallbackErrorMessages = (code: string) => {
return message; return message;
}; };
const LOGIN_REDIRECT_PATH = '/'; const LOGIN_REDIRECT_PATH = formatPath('/');
export const ZSignInFormSchema = z.object({ export const ZSignInFormSchema = z.object({
email: zEmail().min(1), email: zEmail().min(1),
@@ -1,5 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatPath } from '@documenso/lib/constants/app';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { import {
DOCUMENTS_PAGE_SHORTCUT, DOCUMENTS_PAGE_SHORTCUT,
@@ -862,7 +863,7 @@ const PromptLanguageCommands = ({
formData.append('lang', lang); formData.append('lang', lang);
const response = await fetch('/api/locale', { const response = await fetch(formatPath('/api/locale'), {
method: 'post', method: 'post',
body: formData, body: formData,
}); });
@@ -1,4 +1,5 @@
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog'; import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -34,7 +35,9 @@ export const DocumentSigningAuthAccount = ({
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({ await authClient.signOut({
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`, redirectPath: formatPath(
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
),
}); });
} catch { } catch {
setIsSigningOut(false); setIsSigningOut(false);
@@ -1,4 +1,5 @@
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { formatPath } from '@documenso/lib/constants/app';
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';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
@@ -21,10 +22,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
try { try {
setIsSigningOut(true); setIsSigningOut(true);
let redirectPath = '/signin'; let redirectPath = formatPath('/signin');
if (email) { if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`; redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
} }
await authClient.signOut({ await authClient.signOut({
@@ -0,0 +1,195 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { Trans } from '@lingui/react/macro';
import { motion, useReducedMotion } from 'framer-motion';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
const DEMO_BRANDS = [
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#A2E771',
ink: '#162C07',
tint: '#F2FBEA',
sheen: 'rgba(162, 231, 113, 0.32)',
},
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#387BC7',
ink: '#ffffff',
tint: '#EDF3FA',
sheen: 'rgba(56, 123, 199, 0.28)',
},
{
name: 'Documenso',
letter: 'D',
domain: 'noreply@app.documenso.com',
accent: '#9747F5',
ink: '#ffffff',
tint: '#F4EDFE',
sheen: 'rgba(151, 71, 245, 0.26)',
},
];
/**
* Milliseconds each brand is shown before cycling to the next.
*/
const BRAND_CYCLE_INTERVAL_MS = 2400;
export const BrandingUpsell = () => {
const organisation = useCurrentOrganisation();
const isReducedMotion = useReducedMotion();
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
const isStatic = isReducedMotion ?? false;
const brand = DEMO_BRANDS[brandIndex];
return (
<SettingsUpsellCard
planLabel={<Trans>Teams</Trans>}
title={<Trans>Unlock Branding Preferences</Trans>}
description={
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
}
features={[
<Trans key="logo">Your logo on signing pages and emails</Trans>,
<Trans key="details">Company details and website in email footers</Trans>,
<Trans key="teams">Separate branding per team</Trans>,
]}
preview={
<div className="mx-auto w-full max-w-xs">
<div className="flex h-8 items-center justify-between px-1">
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
<Trans>Brand accent</Trans>
</span>
<div className="flex shrink-0 items-center gap-2">
{DEMO_BRANDS.map((dotBrand, index) => (
<motion.div
key={index}
initial={isStatic ? false : undefined}
animate={{
scale: index === brandIndex ? 1.25 : 1,
opacity: index === brandIndex ? 1 : 0.42,
boxShadow:
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
}}
transition={SPRING}
className="h-[13px] w-[13px] rounded-full"
style={{ backgroundColor: dotBrand.accent }}
/>
))}
</div>
</div>
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.tint }}
transition={{ duration: 0.45, ease: EASE }}
className="flex items-center gap-2.5 border-b px-4 py-3"
>
{/* The sender identity never changes — only the tile colours tween per brand. */}
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent, color: brand.ink }}
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
>
{brand.letter}
</motion.div>
{/*
* Hardcoded inks (not theme tokens): this row sits on the
* hardcoded light `tint` band, so it pairs with hardcoded ink
* colours the same way the email sibling pairs its hardcoded
* avatar surfaces (hardcoded surface => hardcoded ink).
*/}
<div className="min-w-0">
<div className="font-medium text-[#0f172a] text-sm">
<p className="truncate">{brand.name}</p>
</div>
<div className="font-mono text-[#64748b] text-xs">
<p className="truncate">{brand.domain}</p>
</div>
</div>
</motion.div>
<div className="px-4 py-3.5">
<p className="font-medium text-sm">
<Trans>Please sign: Example.pdf</Trans>
</p>
<p className="mt-1 text-muted-foreground text-xs">
<Trans>{organisation.name} has invited you to sign this document.</Trans>
</p>
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent, color: brand.ink }}
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
>
<motion.span
key={brandIndex}
initial={isStatic ? false : { scale: 0.96 }}
animate={{ scale: 1 }}
transition={{ ...POP, delay: 0.06 }}
className="inline-block"
>
<Trans>Sign</Trans>
</motion.span>
</motion.div>
</div>
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
<Trans>Company details</Trans>
</span>
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent }}
transition={{ duration: 0.4 }}
className="h-1.5 w-[54px] rounded-full"
style={{ opacity: 0.45 }}
/>
<motion.div
initial={isStatic ? false : undefined}
animate={{ backgroundColor: brand.accent }}
transition={{ duration: 0.4 }}
className="h-1.5 w-[34px] rounded-full"
style={{ opacity: 0.22 }}
/>
</div>
{/*
* Keyed remount replays the sweep per brand. No opacity envelope —
* keyframe arrays are unreliable on strict-mode remounts; both
* endpoints sit outside the overflow-hidden card, so the clip
* provides the fade in/out instead.
*/}
<motion.div
key={`sheen-${brandIndex}`}
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '240%' }}
transition={{ duration: 1.15, ease: 'easeOut' }}
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
/>
</div>
</div>
}
/>
);
};
@@ -0,0 +1,213 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
import { BrandingLogoIcon } from '../branding-logo-icon';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
/**
* Named sender identities cycled through while the preview is in its branded
* state — one per branded cycle step, shown as the sender name and address.
*/
const BRANDED_SENDERS = [
{ name: 'Support', email: 'support@example.com' },
{ name: 'Team', email: 'hello@example.com' },
{ name: 'Sales', email: 'sales@example.com' },
{ name: 'Example', email: 'noreply@example.com' },
];
/**
* How long the initial unbranded (Documenso default) state is shown before
* the first flip starts. Shown exactly once — the cycle never returns to it.
*/
const INITIAL_STATE_DURATION_MS = 2500;
/**
* How long each branded sender identity is shown before cycling to the next,
* giving the viewer time to read the changed address.
*/
const BRANDED_STATE_DURATION_MS = 5000;
/**
* One duration per cycle step: the unbranded state first, then one step per
* named sender identity, derived from the identity count so the two cannot
* drift.
*/
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
export const EmailDomainsUpsell = () => {
const organisation = useCurrentOrganisation();
const isReducedMotion = useReducedMotion();
// Loop from index 1: the unbranded Documenso intro plays exactly once,
// then the cycle rotates through the branded senders only.
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
const isBranded = cycleIndex > 0;
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
const isStatic = isReducedMotion ?? false;
return (
<SettingsUpsellCard
planLabel={<Trans>Enterprise</Trans>}
title={<Trans>Unlock Email Domains</Trans>}
description={
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
}
features={[
<Trans key="journey">Send emails to recipients from your domain</Trans>,
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
]}
ctaLabel={<Trans>Contact Sales</Trans>}
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
ctaExternal
preview={
<div className="mx-auto w-full max-w-xs">
<div className="relative h-8">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={isBranded ? 'chip-on' : 'chip-off'}
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={SPRING}
className={cn(
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
)}
>
{isBranded ? (
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
) : (
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
</span>
</motion.div>
</AnimatePresence>
</div>
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
<div className="flex items-center gap-2 border-b p-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={`logo-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
{/*
* Remounts with its keyed parent on every cycle step so the
* pop replays each change. Single-value spring (POP
* overshoots past 1) instead of scale keyframes — keyframe
* arrays are unreliable on strict-mode remounts.
*/}
<motion.span
initial={isStatic ? false : { scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ scale: POP }}
className="inline-block"
>
{isBranded ? (
<Avatar className="h-8 w-8 border border-solid">
{organisation.avatarImageId && (
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
)}
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
</Avatar>
) : (
<BrandingLogoIcon className="h-8 w-8" />
)}
</motion.span>
</motion.span>
</AnimatePresence>
</div>
<div className="min-w-0">
<div className="font-medium text-sm">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`name-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.28, ease: EASE }}
className="flex min-w-0 items-center gap-1.5"
>
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
{isBranded && (
<motion.span
initial={isStatic ? false : { scale: 0, rotate: -40 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ ...POP, delay: 0.12 }}
className="shrink-0"
>
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
</motion.span>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="font-mono text-muted-foreground text-xs">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`addr-${cycleIndex}`}
initial={isStatic ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.28, ease: EASE }}
className="truncate"
>
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
</motion.p>
</AnimatePresence>
</div>
</div>
</div>
<div className="p-4">
<p className="font-medium text-sm">
<Trans>Please sign: Example.pdf</Trans>
</p>
<p className="mt-1 text-muted-foreground text-xs">
<Trans>{organisation.name} has invited you to sign this document.</Trans>
</p>
</div>
{/*
* Keyed remount replays the sweep on every cycle step. No opacity
* envelope — keyframe arrays are unreliable on strict-mode
* remounts; both endpoints sit outside the overflow-hidden card,
* so the clip provides the fade in/out instead.
*/}
<motion.div
key={`sheen-${cycleIndex}`}
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '240%' }}
transition={{ duration: 1.2, ease: 'easeOut' }}
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
/>
</div>
</div>
}
/>
);
};
@@ -0,0 +1,9 @@
/**
* Shared motion vocabulary for the settings upsell previews. Values ported
* from the design prototype (`design/SSO Upsell.dc.html`).
*/
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
export const EASE = [0.22, 0.61, 0.36, 1] as const;
@@ -0,0 +1,118 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
export type SettingsUpsellCardProps = {
planLabel: ReactNode;
title: ReactNode;
description: ReactNode;
features: ReactNode[];
preview: ReactNode;
/**
* CTA label. Defaults to "Upgrade Plan".
*/
ctaLabel?: ReactNode;
/**
* CTA destination. Defaults to the organisation billing settings page.
*/
ctaTo?: string;
/**
* Render the CTA as an external link (new tab) instead of an internal route.
*/
ctaExternal?: boolean;
};
/**
* Shared split-card layout for claim-gated settings upsells on Documenso
* Cloud. The left pane pitches the feature (plan badge, title, description,
* feature list, upgrade CTA); the right pane renders a decorative scenario
* preview supplied by the caller.
*
* Callers decide *when* to render this (cloud + missing claim flag).
*/
export const SettingsUpsellCard = ({
planLabel,
title,
description,
features,
preview,
ctaLabel,
ctaTo,
ctaExternal = false,
}: SettingsUpsellCardProps) => {
const organisation = useCurrentOrganisation();
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
const ctaContent = (
<>
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
<ArrowRightIcon className="ml-2 h-4 w-4" />
</>
);
return (
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
{/*
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
* minimum, so long preview content (e.g. a wide mono domain line) would
* otherwise widen the right track beyond its 0.92fr share — and
* re-balance the whole grid on every preview cycle (layout shift).
*/}
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
<Badge size="small">
<LockIcon className="mr-1 h-3 w-3" />
<span className="uppercase">{planLabel}</span>
</Badge>
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
<ul className="mt-6 space-y-3">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-2.5 text-sm">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
</span>
{feature}
</li>
))}
</ul>
{canManageBilling ? (
<Button className="mt-8" asChild>
{ctaExternal ? (
<a href={ctaHref} target="_blank" rel="noreferrer">
{ctaContent}
</a>
) : (
<Link to={ctaHref}>{ctaContent}</Link>
)}
</Button>
) : (
<p className="mt-8 text-muted-foreground text-xs">
<Trans>Contact your organisation owner to upgrade plans.</Trans>
</p>
)}
</div>
<div
aria-hidden="true"
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
>
{preview}
</div>
</div>
);
};
@@ -0,0 +1,260 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { FingerprintIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { EASE, POP, SPRING } from './motion';
import { SettingsUpsellCard } from './settings-upsell-card';
import { useTimedCycle } from './use-timed-cycle';
export const SsoPortalUpsell = () => {
const isReducedMotion = useReducedMotion();
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
return (
<SettingsUpsellCard
planLabel={<Trans>Enterprise</Trans>}
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
description={
<Trans>
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
</Trans>
}
features={[
<Trans key="oidc">Works with any OIDC provider Okta, Entra ID, Google and more</Trans>,
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
]}
ctaLabel={<Trans>Contact Sales</Trans>}
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
ctaExternal
preview={
<div className="mx-auto w-full max-w-xs">
<div className="relative h-[236px]">
<AnimatePresence mode="wait" initial={false}>
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
{sceneIndex === 1 && <RedirectScene key="redirect" />}
{sceneIndex === 2 && <SuccessScene key="success" />}
</AnimatePresence>
</div>
</div>
}
/>
);
};
/**
* Absolute-positioned panel each scene renders in, handling the shared
* slide-and-fade transition between scenes.
*/
const ScenePanel = ({ children }: { children: ReactNode }) => {
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, scale: 0.98 }}
transition={{ duration: 0.34, ease: EASE }}
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
>
{children}
</motion.div>
);
};
/**
* Scene 1: the organisation's SSO portal, with a timed faux press on the
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
*
* When `isStatic` is set (reduced motion) every element renders with
* `initial={false}`, skipping entrance and press animations.
*/
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
const organisation = useCurrentOrganisation();
const rise = (delay: number) => ({
initial: isStatic ? false : { y: 10, opacity: 0 },
animate: { y: 0, opacity: 1 },
transition: { ...SPRING, delay },
});
return (
<ScenePanel>
<motion.div
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ ...POP, delay: 0.04 }}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
{([...organisation.name][0] ?? 'D').toUpperCase()}
</div>
</motion.div>
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
<Trans>Welcome to {organisation.name}</Trans>
</motion.p>
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
<Trans>Single sign-on</Trans>
</motion.p>
<div className="relative mt-4 w-full">
<motion.div
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
transition={{
y: { ...SPRING, delay: 0.24 },
opacity: { duration: 0.3, delay: 0.24 },
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
}}
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
>
<span>
<Trans>Continue with SSO</Trans>
</span>
<motion.div
initial={isStatic ? false : { x: '-130%' }}
animate={{ x: '150%' }}
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
className="absolute top-0 bottom-0 left-[20%] w-3/5"
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
/>
</motion.div>
<motion.svg
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
animate={{
x: [30, 30, 0, 0, 0],
y: [30, 30, 0, 0, 0],
opacity: [0, 1, 1, 1, 0],
scale: [1, 1, 1, 0.82, 1],
}}
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
width={17}
height={17}
viewBox="0 0 24 24"
strokeWidth={1.4}
strokeLinejoin="round"
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
>
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
</motion.svg>
</div>
</ScenePanel>
);
};
/**
* Scene 2: redirecting to the identity provider, with a rotating ring around
* a fingerprint tile and a filling progress bar.
*/
const RedirectScene = () => {
return (
<ScenePanel>
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
className="absolute inset-0 rounded-full border-2"
style={{ borderTopColor: '#A2E771' }}
/>
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
</div>
</div>
<motion.p
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.08 }}
className="mt-3.5 max-w-[22ch] text-sm"
>
<Trans>Redirecting to your identity provider</Trans>
</motion.p>
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
<motion.div
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: 1.35, ease: 'easeInOut' }}
className="h-full rounded-full bg-documenso"
/>
</div>
</ScenePanel>
);
};
/**
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
* a drawn checkmark and the signed-in member's email.
*/
const SuccessScene = () => {
const { user } = useSession();
return (
<ScenePanel>
<div className="relative h-10 w-10">
<motion.div
initial={{ scale: 0.7, opacity: 0.85 }}
animate={{ scale: 2.1, opacity: 0 }}
transition={{ duration: 1.1, ease: 'easeOut' }}
className="absolute inset-0 rounded-full border-2"
style={{ borderColor: '#A2E771' }}
/>
<motion.div
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={POP}
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
>
<svg
width={19}
height={19}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
className="text-documenso-900"
>
<motion.path
d="M20 6 9 17l-5-5"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
/>
</svg>
</motion.div>
</div>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.16 }}
className="mt-3.5 font-semibold text-sm"
>
<Trans>Signed in</Trans>
</motion.p>
<motion.p
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ...SPRING, delay: 0.24 }}
className="mt-1 font-mono text-muted-foreground text-xs"
>
{user.email}
</motion.p>
</ScenePanel>
);
};
/**
* Milliseconds each scene is shown before advancing: portal, redirect,
* success.
*/
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
@@ -0,0 +1,47 @@
import { useReducedMotion } from 'framer-motion';
import { useEffect, useState } from 'react';
/**
* Cycles an index through `durations.length` steps, waiting `durations[i]`
* milliseconds on step `i` before advancing to the next.
*
* When the cycle wraps past the last step it continues from `loopStartIndex`
* (default `0`), letting consumers play intro-only steps exactly once and
* then loop through the remaining steps forever.
*
* Under `prefers-reduced-motion` the cycle never starts and the index stays
* at 0, so consumers render their initial state statically.
*
* Pass module-level constants for `durations` and `loopStartIndex` — their
* identities are intentionally not dependencies.
*/
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
const [index, setIndex] = useState(0);
const isReducedMotion = useReducedMotion();
useEffect(() => {
if (isReducedMotion || durations.length === 0) {
setIndex(0);
return;
}
let current = 0;
let timeout: ReturnType<typeof setTimeout>;
const tick = () => {
const next = current + 1;
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
setIndex(current);
timeout = setTimeout(tick, durations[current]);
};
timeout = setTimeout(tick, durations[0]);
return () => clearTimeout(timeout);
}, [isReducedMotion]);
return index;
};
+10 -7
View File
@@ -1,5 +1,6 @@
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';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { createPublicEnv } from '@documenso/lib/utils/env'; import { createPublicEnv } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n'; import { extractLocaleData } from '@documenso/lib/utils/i18n';
@@ -20,7 +21,6 @@ import {
useMatches, useMatches,
} from 'react-router'; } from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import type { Route } from './+types/root'; 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';
@@ -68,6 +68,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
lang, lang,
theme: getTheme(), theme: getTheme(),
disableAnimations, disableAnimations,
basePath: getBasePath(),
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all // Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child // SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute. // routes that need it) can carry the matching nonce attribute.
@@ -90,10 +91,10 @@ export async function loader({ context, request }: Route.LoaderArgs) {
} }
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const { theme } = useLoaderData<typeof loader>() || {}; const { theme, basePath } = useLoaderData<typeof loader>() || {};
return ( return (
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme"> <ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
<LayoutContent>{children}</LayoutContent> <LayoutContent>{children}</LayoutContent>
</ThemeProvider> </ThemeProvider>
); );
@@ -111,6 +112,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const [theme] = useTheme(); const [theme] = useTheme();
const basePath = data.basePath ?? '';
// Recipient routes (signing pages) put `documenso-branded` on <body> so the // Recipient routes (signing pages) put `documenso-branded` on <body> so the
// <style> block from `RecipientBranding` applies to BOTH the main tree and // <style> block from `RecipientBranding` applies to BOTH the main tree and
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the // any portaled content (Radix dialogs/popovers/dropdowns mount outside the
@@ -126,11 +129,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning> <html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href={`${basePath}/site.webmanifest`} />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
<Meta /> <Meta />
<Links nonce={nonce(cspNonce)} /> <Links nonce={nonce(cspNonce)} />
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -17,6 +17,7 @@ import {
type TBrandingPreferencesFormSchema, type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form'; } from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@@ -121,11 +122,18 @@ export default function OrganisationSettingsBrandingPage() {
? t`Here you can set branding preferences for your team.` ? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`; : t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
const brandingPreferencesFormEnabled =
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
return ( return (
<div> <div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} /> <SettingsHeader
title={settingsHeaderText}
subtitle={settingsHeaderSubtitle}
hideDivider={!brandingPreferencesFormEnabled}
/>
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? ( {brandingPreferencesFormEnabled ? (
<section> <section>
<BrandingPreferencesForm <BrandingPreferencesForm
context="Organisation" context="Organisation"
@@ -160,6 +168,8 @@ export default function OrganisationSettingsBrandingPage() {
</Alert> </Alert>
)} )}
</section> </section>
) : IS_DOCUMENSO_CLOUD() ? (
<BrandingUpsell />
) : ( ) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral"> <Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains'; import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types'; import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
@@ -27,8 +27,9 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog'; import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id'; import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) { export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
const { t } = useLingui(); const { t } = useLingui();
@@ -96,10 +97,23 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[]; ] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
}, [organisation]); }, [organisation]);
const pageHeader = t`Email Domain Settings`;
const pageSubtitle = t`Manage your email domain settings.`;
if (!IS_BILLING_ENABLED()) { if (!IS_BILLING_ENABLED()) {
return null; return null;
} }
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
return (
<div>
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
<EmailDomainsUpsell />
</div>
);
}
if (isLoadingEmailDomain) { if (isLoadingEmailDomain) {
return <SpinnerBox className="py-32" />; return <SpinnerBox className="py-32" />;
} }
@@ -132,7 +146,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
return ( return (
<div> <div>
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}> <SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
<OrganisationEmailCreateDialog emailDomain={emailDomain} /> <OrganisationEmailCreateDialog emailDomain={emailDomain} />
</SettingsHeader> </SettingsHeader>
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -9,6 +9,7 @@ import { Link } from 'react-router';
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog'; import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table'; import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@@ -41,6 +42,8 @@ export default function OrganisationSettingsEmailDomains() {
<section> <section>
<OrganisationEmailDomainsDataTable /> <OrganisationEmailDomainsDataTable />
</section> </section>
) : IS_DOCUMENSO_CLOUD() ? (
<EmailDomainsUpsell />
) : ( ) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral"> <Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
@@ -1,4 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations'; import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { import {
@@ -28,6 +29,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
@@ -63,10 +65,33 @@ export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui(); const { t } = useLingui();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true;
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } = const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.get.useQuery({ trpc.enterprise.organisation.authenticationPortal.get.useQuery(
organisationId: organisation.id, {
}); organisationId: organisation.id,
},
{
// The endpoint rejects orgs without the claim flag, so don't fire
// requests that are guaranteed to error.
enabled: isAuthenticationPortalEnabled,
},
);
if (!isAuthenticationPortalEnabled && IS_DOCUMENSO_CLOUD()) {
return (
<div>
<SettingsHeader
hideDivider
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
<SsoPortalUpsell />
</div>
);
}
if (isLoadingAuthenticationPortal || !authenticationPortal) { if (isLoadingAuthenticationPortal || !authenticationPortal) {
return <SpinnerBox className="py-32" />; return <SpinnerBox className="py-32" />;
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -17,6 +17,7 @@ import {
type TBrandingPreferencesFormSchema, type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form'; } from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() { export default function TeamsSettingsPage() {
@@ -155,6 +156,8 @@ export default function TeamsSettingsPage() {
</Alert> </Alert>
)} )}
</section> </section>
) : IS_DOCUMENSO_CLOUD() ? (
<BrandingUpsell />
) : ( ) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral"> <Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
+3 -2
View File
@@ -5,8 +5,9 @@
* *
* No translations required. * No translations required.
*/ */
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatPath } from '@documenso/lib/constants/app';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
export const loader = () => { export const loader = () => {
@@ -147,7 +148,7 @@ export default function EmbedPlaygroundPage() {
return inputToken; return inputToken;
} }
const response = await fetch('/api/v2/embedding/create-presign-token', { const response = await fetch(formatPath('/api/v2/embedding/create-presign-token'), {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${inputToken}`, Authorization: `Bearer ${inputToken}`,
+1 -1
View File
@@ -44,7 +44,7 @@
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"colord": "^2.9.3", "colord": "^2.9.3",
"content-disposition": "^1.0.1", "content-disposition": "^1.0.1",
"framer-motion": "^12.23.24", "framer-motion": "^12.43.0",
"hono": "^4.12.14", "hono": "^4.12.14",
"hono-react-router-adapter": "^0.6.5", "hono-react-router-adapter": "^0.6.5",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
+2 -2
View File
@@ -3,12 +3,12 @@
"short_name": "Documenso", "short_name": "Documenso",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "./android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/android-chrome-512x512.png", "src": "./android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
+5
View File
@@ -3,4 +3,9 @@ import type { Config } from '@react-router/dev/config';
export default { export default {
appDirectory: 'app', appDirectory: 'app',
ssr: true, ssr: true,
// Must never be undefined and must start with the raw Vite `base` value,
// otherwise @react-router/dev crashes / exits on `react-router dev`. Both are
// kept without a trailing slash so they match exactly, and so the bare
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
} satisfies Config; } satisfies Config;
@@ -1,3 +1,4 @@
import { formatPath } from '@documenso/lib/constants/app';
import { z } from 'zod'; import { z } from 'zod';
import { type TDetectFieldsRequest, ZNormalizedFieldWithContextSchema } from './detect-fields.types'; import { type TDetectFieldsRequest, ZNormalizedFieldWithContextSchema } from './detect-fields.types';
@@ -69,7 +70,7 @@ export const detectFields = async ({
onError, onError,
signal, signal,
}: DetectFieldsOptions): Promise<void> => { }: DetectFieldsOptions): Promise<void> => {
const response = await fetch('/api/ai/detect-fields', { const response = await fetch(formatPath('/api/ai/detect-fields'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -1,3 +1,4 @@
import { formatPath } from '@documenso/lib/constants/app';
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema'; import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { z } from 'zod'; import { z } from 'zod';
@@ -70,7 +71,7 @@ export const detectRecipients = async ({
onError, onError,
signal, signal,
}: DetectRecipientsOptions): Promise<void> => { }: DetectRecipientsOptions): Promise<void> => {
const response = await fetch('/api/ai/detect-recipients', { const response = await fetch(formatPath('/api/ai/detect-recipients'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+13
View File
@@ -14,9 +14,22 @@ import { getLoadContext } from './hono/server/load-context.js';
import server from './hono/server/router.js'; import server from './hono/server/router.js';
import * as build from './index.js'; import * as build from './index.js';
// Sub-path the app is served under (e.g. "/ESign"). Empty = root.
// Must match the basePath used by the Hono router and the Vite `base`/RR
// `basename` so that hashed asset URLs like `/ESign/assets/app-xxx.css`
// resolve to files on disk at `build/client/assets/app-xxx.css`.
const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/$/, '');
server.use( server.use(
serveStatic({ serveStatic({
root: 'build/client', root: 'build/client',
rewriteRequestPath: (path) => {
if (basePath && (path === basePath || path.startsWith(`${basePath}/`))) {
const stripped = path.slice(basePath.length);
return stripped === '' ? '/' : stripped;
}
return path;
},
onFound: (path, c) => { onFound: (path, c) => {
if (path.startsWith('build/client/assets')) { if (path.startsWith('build/client/assets')) {
// Hard cache assets with hashed file names. // Hard cache assets with hashed file names.
+3 -1
View File
@@ -46,7 +46,9 @@ export interface HonoEnv {
}; };
} }
const app = new Hono<HonoEnv>(); const basePath = (env('NEXT_PUBLIC_BASE_PATH') ?? '').replace(/\/$/, '');
const app = new Hono<HonoEnv>().basePath(basePath || '/');
/** /**
* Database-backed rate limiting for API routes. * Database-backed rate limiting for API routes.
+4 -2
View File
@@ -1,4 +1,4 @@
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app'; import { API_V2_BETA_URL, API_V2_URL, formatPath } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context'; import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
@@ -11,8 +11,10 @@ type OpenApiTrpcServerHandlerOptions = {
}; };
export const openApiTrpcServerHandler = async (c: Context, { isBeta }: OpenApiTrpcServerHandlerOptions) => { export const openApiTrpcServerHandler = async (c: Context, { isBeta }: OpenApiTrpcServerHandlerOptions) => {
const endpoint = formatPath(isBeta ? API_V2_BETA_URL : API_V2_URL) as `/${string}`;
return createOpenApiFetchHandler<typeof appRouter>({ return createOpenApiFetchHandler<typeof appRouter>({
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL, endpoint,
router: appRouter, router: appRouter,
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }), createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
req: c.req.raw, req: c.req.raw,
+6 -1
View File
@@ -1,3 +1,4 @@
import { formatPath } from '@documenso/lib/constants/app';
import { createTrpcContext } from '@documenso/trpc/server/context'; import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler'; import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
@@ -5,10 +6,14 @@ import { trpcServer } from '@hono/trpc-server';
/** /**
* Trpc server for internal routes like /api/trpc/* * Trpc server for internal routes like /api/trpc/*
*
* `endpoint` must include the sub-path prefix (e.g. "/ESign") because the
* @hono/trpc-server adapter slices the prefix off the full URL pathname to
* compute the procedure name. Hono's `basePath` doesn't rewrite the URL.
*/ */
export const reactRouterTrpcServer = trpcServer({ export const reactRouterTrpcServer = trpcServer({
router: appRouter, router: appRouter,
endpoint: '/api/trpc', endpoint: formatPath('/api/trpc'),
createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }), createContext: async (_, c) => createTrpcContext({ c, requestSource: 'app' }),
onError: (opts) => handleTrpcRouterError(opts, 'trpc'), onError: (opts) => handleTrpcRouterError(opts, 'trpc'),
}); });
+4
View File
@@ -23,6 +23,10 @@ const cMapsDir = normalizePath(path.join(pdfjsDistPath, 'cmaps'));
* Do not configure any envs here. * Do not configure any envs here.
*/ */
export default defineConfig({ export default defineConfig({
// No trailing slash: the React Router dev server requires its `basename` to
// start with this raw value (see react-router.config.ts). Vite normalizes
// and joins asset URLs correctly either way.
base: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
css: { css: {
postcss: { postcss: {
plugins: [tailwindcss, autoprefixer], plugins: [tailwindcss, autoprefixer],
+6
View File
@@ -50,6 +50,12 @@ ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF" ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY" ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
# Sub-path the app is served under (e.g. "/ESign"). Empty = root.
# Baked into the client bundle by Vite/React Router at build time; also
# required at runtime for SSR so window.__ENV__ exposes it to the client.
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH="$NEXT_PUBLIC_BASE_PATH"
# Telemetry credentials (optional, baked into image at build time) # Telemetry credentials (optional, baked into image at build time)
ARG NEXT_PRIVATE_TELEMETRY_KEY="" ARG NEXT_PRIVATE_TELEMETRY_KEY=""
ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY" ENV NEXT_PRIVATE_TELEMETRY_KEY="$NEXT_PRIVATE_TELEMETRY_KEY"
+14 -14
View File
@@ -399,7 +399,7 @@
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"colord": "^2.9.3", "colord": "^2.9.3",
"content-disposition": "^1.0.1", "content-disposition": "^1.0.1",
"framer-motion": "^12.23.24", "framer-motion": "^12.43.0",
"hono": "^4.12.14", "hono": "^4.12.14",
"hono-react-router-adapter": "^0.6.5", "hono-react-router-adapter": "^0.6.5",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -20310,13 +20310,13 @@
} }
}, },
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.23.24", "version": "12.43.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-dom": "^12.23.23", "motion-dom": "^12.43.0",
"motion-utils": "^12.23.6", "motion-utils": "^12.39.0",
"tslib": "^2.4.0" "tslib": "^2.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -24677,18 +24677,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "12.23.23", "version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-utils": "^12.23.6" "motion-utils": "^12.39.0"
} }
}, },
"node_modules/motion-utils": { "node_modules/motion-utils": {
"version": "12.23.6", "version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mqtt": { "node_modules/mqtt": {
@@ -33933,7 +33933,7 @@
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cmdk": "^0.2.1", "cmdk": "^0.2.1",
"colord": "^2.9.3", "colord": "^2.9.3",
"framer-motion": "^12.23.24", "framer-motion": "^12.43.0",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"pdfjs-dist": "5.4.296", "pdfjs-dist": "5.4.296",
+2 -4
View File
@@ -1,4 +1,4 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import type { ClientResponse, InferRequestType } from 'hono/client'; import type { ClientResponse, InferRequestType } from 'hono/client';
import { hc } from 'hono/client'; import { hc } from 'hono/client';
@@ -36,8 +36,6 @@ type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$
export class AuthClient { export class AuthClient {
public client: AuthClientType; public client: AuthClientType;
private signOutredirectPath: string = '/signin';
constructor(options: { baseUrl: string }) { constructor(options: { baseUrl: string }) {
this.client = hc<AuthAppType>(options.baseUrl); this.client = hc<AuthAppType>(options.baseUrl);
} }
@@ -45,7 +43,7 @@ export class AuthClient {
public async signOut({ redirectPath }: { redirectPath?: string } = {}) { public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
await this.client.signout.$post(); await this.client.signout.$post();
window.location.href = redirectPath ?? this.signOutredirectPath; window.location.href = redirectPath ?? formatPath('/signin');
} }
public async signOutAllSessions() { public async signOutAllSessions() {
@@ -1,4 +1,4 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { import {
isDisposableEmail, isDisposableEmail,
isEmailDomainAllowedForSignup, isEmailDomainAllowedForSignup,
@@ -121,7 +121,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Check if signups are disabled for this provider. // Check if signups are disabled for this provider.
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) { if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL()); const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled); errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -130,7 +130,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Check domain restriction for new SSO users. // Check domain restriction for new SSO users.
if (!isEmailDomainAllowedForSignup(email)) { if (!isEmailDomainAllowedForSignup(email)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL()); const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled); errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -141,7 +141,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
const additionalBlockedDomains = await getEmailBlocklistDomains(); const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) { if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL()); const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail); errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
@@ -213,15 +213,18 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' '); let [redirectState, redirectPath] = storedRedirectPath.split(' ');
// The sub-path aware root, e.g. "/" or "/ESign/".
const defaultRedirectPath = formatPath('/');
if (redirectState !== storedState || !redirectPath) { if (redirectState !== storedState || !redirectPath) {
redirectPath = '/'; redirectPath = defaultRedirectPath;
} }
if (!isValidReturnTo(redirectPath)) { if (!isValidReturnTo(redirectPath)) {
redirectPath = '/'; redirectPath = defaultRedirectPath;
} }
redirectPath = normalizeReturnTo(redirectPath) || '/'; redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier); const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
@@ -1,4 +1,5 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email'; import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { formatPath } from '@documenso/lib/constants/app';
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth'; import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains'; import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
@@ -56,7 +57,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
if (existingAccount) { if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c); await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(`/o/${orgUrl}`, 302); return c.redirect(formatPath(`/o/${orgUrl}`), 302);
} }
let userToLink = await prisma.user.findFirst({ let userToLink = await prisma.user.findFirst({
+28 -7
View File
@@ -1,5 +1,25 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* Derive the default redirect target ("/" at the root, "/ESign/" when served under a sub-path).
*/
const getDefaultRedirect = () => {
try {
const pathname = new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
return `${pathname}/`;
} catch {
return '/';
}
};
const getWebAppOrigin = () => {
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
} catch {
return NEXT_PUBLIC_WEBAPP_URL();
}
};
/** /**
* Handle an optional redirect path. * Handle an optional redirect path.
*/ */
@@ -10,19 +30,20 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL()); const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) { if (url.origin !== getWebAppOrigin()) {
window.location.href = '/'; window.location.href = getDefaultRedirect();
} else { } else {
window.location.href = redirectUrl; window.location.href = redirectUrl;
} }
}; };
export const handleSignInRedirect = (redirectUrl: string = '/') => { export const handleSignInRedirect = (redirectUrl?: string) => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL()); const target = redirectUrl ?? getDefaultRedirect();
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) { if (url.origin !== getWebAppOrigin()) {
window.location.href = '/'; window.location.href = getDefaultRedirect();
} else { } else {
window.location.href = redirectUrl; window.location.href = target;
} }
}; };
+4 -1
View File
@@ -12,7 +12,10 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => { export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
const requestHeaders = headers ?? {}; const requestHeaders = headers ?? {};
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL()); // Note: the path must be appended rather than passed as the `new URL()` path
// argument, since a leading-slash path replaces the sub-path that
// NEXT_PUBLIC_WEBAPP_URL may carry (e.g. https://host/ESign).
const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/limits`);
if (teamId) { if (teamId) {
requestHeaders['team-id'] = teamId.toString(); requestHeaders['team-id'] = teamId.toString();
+46
View File
@@ -6,6 +6,42 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000'; export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
/**
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
* Returns an empty string when served at root.
*
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
* into the Vite/React Router build). Falls back to the pathname of
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
* variable is unset.
*
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
* for cases where the raw prefix itself is needed, such as path comparisons.
*/
export const getBasePath = (): string => {
const explicit = env('NEXT_PUBLIC_BASE_PATH');
if (explicit) {
return explicit.replace(/\/$/, '');
}
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
} catch {
return '';
}
};
/**
* Prefix a root-relative path with the app's base path.
*
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
* `/api/trpc` otherwise.
*/
export const formatPath = (path: string): string => {
return `${getBasePath()}${path}`;
};
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () => export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL(); env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -17,6 +53,14 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true'; export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
/**
* Whether this instance is Documenso Cloud (managed SaaS).
*
* Used so we can show a different UI for Documenso Cloud and self-hosted instances since
* there are things like billing, upsells, documenso links, etc that don't make sense for self-hosted instances.
*/
export const IS_DOCUMENSO_CLOUD = () => env('NEXT_PUBLIC_IS_DOCUMENSO_CLOUD') === 'true';
export const API_V2_BETA_URL = '/api/v2-beta'; export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2'; export const API_V2_URL = '/api/v2';
@@ -99,3 +143,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
return value; return value;
}; };
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,10 +1,10 @@
/* eslint-disable turbo/no-undeclared-env-vars */ /* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL, getBasePath } from '../constants/app';
import { env } from '../utils/env'; import { env } from '../utils/env';
export const getBaseUrl = () => { export const getBaseUrl = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return ''; return getBasePath();
} }
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL(); const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
+3 -1
View File
@@ -2,6 +2,8 @@ import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { formatPath } from '../../constants/app';
export type GetFileOptions = { export type GetFileOptions = {
type: DocumentDataType; type: DocumentDataType;
data: string; data: string;
@@ -36,7 +38,7 @@ const getFileFromBytes64 = (data: string) => {
}; };
const getFileFromS3 = async (key: string) => { const getFileFromS3 = async (key: string) => {
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, { const getPresignedUrlResponse = await fetch(formatPath('/api/files/presigned-get-url'), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+2 -1
View File
@@ -1,5 +1,6 @@
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types'; import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { formatPath } from '../../constants/app';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
type File = { type File = {
@@ -38,7 +39,7 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
formData.append('file', properFile); formData.append('file', properFile);
const response = await fetch('/api/files/upload-pdf', { const response = await fetch(formatPath('/api/files/upload-pdf'), {
method: 'POST', method: 'POST',
headers: buildUploadAuthHeaders(options), headers: buildUploadAuthHeaders(options),
body: formData, body: formData,
+27 -3
View File
@@ -1,4 +1,15 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { getBasePath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* The origin of the web app, ignoring any sub-path NEXT_PUBLIC_WEBAPP_URL carries.
*/
const getWebAppOrigin = () => {
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
} catch {
return NEXT_PUBLIC_WEBAPP_URL();
}
};
export const isValidReturnTo = (returnTo?: string) => { export const isValidReturnTo = (returnTo?: string) => {
if (!returnTo) { if (!returnTo) {
@@ -10,7 +21,10 @@ export const isValidReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo); const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL()); const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) { // Compare against the origin, not the raw env value: when the app is served
// under a sub-path NEXT_PUBLIC_WEBAPP_URL is e.g. "https://host/ESign", which
// never equals a URL's origin ("https://host").
if (returnToUrl.origin !== getWebAppOrigin()) {
return false; return false;
} }
@@ -30,7 +44,17 @@ export const normalizeReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo); const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL()); const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`; const basePath = getBasePath();
let pathname = returnToUrl.pathname;
// A root-relative returnTo ("/inbox") resolves to a pathname without the
// sub-path, so re-apply it when it is missing.
if (basePath && pathname !== basePath && !pathname.startsWith(`${basePath}/`)) {
pathname = `${basePath}${pathname}`;
}
return `${pathname}${returnToUrl.search}${returnToUrl.hash}`;
} catch { } catch {
return undefined; return undefined;
} }
+4 -3
View File
@@ -18,7 +18,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { FaUsers } from 'react-icons/fa6'; import { FaUsers } from 'react-icons/fa6';
import { IS_BILLING_ENABLED } from '../constants/app'; import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '../constants/app';
import { canExecuteOrganisationAction } from './organisations'; import { canExecuteOrganisationAction } from './organisations';
import { canExecuteTeamAction } from './teams'; import { canExecuteTeamAction } from './teams';
@@ -73,6 +73,7 @@ export const getSettingsNavGroups = ({
hasManageableBillingOrgs, hasManageableBillingOrgs,
}: GetSettingsNavGroupsArgs): SettingsNavGroups => { }: GetSettingsNavGroupsArgs): SettingsNavGroups => {
const isBillingEnabled = IS_BILLING_ENABLED(); const isBillingEnabled = IS_BILLING_ENABLED();
const isDocumensoCloud = IS_DOCUMENSO_CLOUD();
const canManageOrg = const canManageOrg =
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole); organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
@@ -126,7 +127,7 @@ export const getSettingsNavGroups = ({
label: msg`Certificates`, label: msg`Certificates`,
isSubNav: true, isSubNav: true,
}, },
...(isBillingEnabled && organisation.organisationClaim.flags.emailDomains ...((isBillingEnabled && organisation.organisationClaim.flags.emailDomains) || isDocumensoCloud
? [ ? [
{ {
key: 'email-domains', key: 'email-domains',
@@ -154,7 +155,7 @@ export const getSettingsNavGroups = ({
label: msg`Groups`, label: msg`Groups`,
icon: GroupIcon, icon: GroupIcon,
}, },
...(isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal ...((isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal) || isDocumensoCloud
? [ ? [
{ {
key: 'sso', key: 'sso',
@@ -1,3 +1,4 @@
import { formatPath } from '@documenso/lib/constants/app';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -23,7 +24,7 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
formData.append('lang', lang); formData.append('lang', lang);
await fetch('/api/locale', { await fetch(formatPath('/api/locale'), {
method: 'post', method: 'post',
body: formData, body: formData,
}); });
+1 -1
View File
@@ -62,7 +62,7 @@
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cmdk": "^0.2.1", "cmdk": "^0.2.1",
"colord": "^2.9.3", "colord": "^2.9.3",
"framer-motion": "^12.23.24", "framer-motion": "^12.43.0",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"pdfjs-dist": "5.4.296", "pdfjs-dist": "5.4.296",