mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
Merge branch 'main' into chore/add-manual-translations
This commit is contained in:
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-1
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+14
-14
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
+147
-147
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user