mirror of
https://github.com/documenso/documenso.git
synced 2026-08-22 14:22:21 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9a4cc42fd |
@@ -81,7 +81,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -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 { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { 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={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
to="https://documen.so/enterprise-cta"
|
||||||
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,6 +1,5 @@
|
|||||||
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';
|
||||||
@@ -45,7 +44,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
|||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_REDIRECT_PATH = formatPath('/');
|
const LOGIN_REDIRECT_PATH = '/';
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: zEmail().min(1),
|
email: zEmail().min(1),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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,
|
||||||
@@ -863,7 +862,7 @@ const PromptLanguageCommands = ({
|
|||||||
|
|
||||||
formData.append('lang', lang);
|
formData.append('lang', lang);
|
||||||
|
|
||||||
const response = await fetch(formatPath('/api/locale'), {
|
const response = await fetch('/api/locale', {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-4
@@ -1,5 +1,4 @@
|
|||||||
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';
|
||||||
@@ -35,9 +34,7 @@ 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: formatPath(
|
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
`/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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';
|
||||||
@@ -22,10 +21,10 @@ export const DocumentSigningAuthPageView = ({ email, emailHasAccount }: Document
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
let redirectPath = formatPath('/signin');
|
let redirectPath = '/signin';
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
redirectPath = emailHasAccount ? formatPath(`/signin#email=${email}`) : formatPath(`/signup#email=${email}`);
|
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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];
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
+7
-10
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
@@ -21,6 +20,7 @@ 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,7 +68,6 @@ 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.
|
||||||
@@ -91,10 +90,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, basePath } = useLoaderData<typeof loader>() || {};
|
const { theme } = useLoaderData<typeof loader>() || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction={`${basePath ?? ''}/api/theme`}>
|
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
||||||
<LayoutContent>{children}</LayoutContent>
|
<LayoutContent>{children}</LayoutContent>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
@@ -112,8 +111,6 @@ 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
|
||||||
@@ -129,11 +126,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={`${basePath}/apple-touch-icon.png`} />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
|
<link rel="icon" type="image/png" sizes="16x16" href="/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={`${basePath}/site.webmanifest`} />
|
<link rel="manifest" href="/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, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } 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,7 +17,6 @@ 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';
|
||||||
|
|
||||||
@@ -122,18 +121,11 @@ 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
|
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||||
title={settingsHeaderText}
|
|
||||||
subtitle={settingsHeaderSubtitle}
|
|
||||||
hideDivider={!brandingPreferencesFormEnabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{brandingPreferencesFormEnabled ? (
|
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
||||||
<section>
|
<section>
|
||||||
<BrandingPreferencesForm
|
<BrandingPreferencesForm
|
||||||
context="Organisation"
|
context="Organisation"
|
||||||
@@ -168,8 +160,6 @@ 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, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } 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,9 +27,8 @@ 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.email-domains.$id';
|
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||||
|
|
||||||
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -97,23 +96,10 @@ 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" />;
|
||||||
}
|
}
|
||||||
@@ -146,7 +132,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
|
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
||||||
<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, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } 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,7 +9,6 @@ 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';
|
||||||
|
|
||||||
@@ -42,8 +41,6 @@ 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,5 +1,4 @@
|
|||||||
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 {
|
||||||
@@ -29,7 +28,6 @@ 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
|
||||||
@@ -65,33 +63,10 @@ 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, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } 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,7 +17,6 @@ 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() {
|
||||||
@@ -156,8 +155,6 @@ 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,9 +5,8 @@
|
|||||||
*
|
*
|
||||||
* No translations required.
|
* No translations required.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatPath } from '@documenso/lib/constants/app';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 = () => {
|
||||||
@@ -148,7 +147,7 @@ export default function EmbedPlaygroundPage() {
|
|||||||
return inputToken;
|
return inputToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(formatPath('/api/v2/embedding/create-presign-token'), {
|
const response = await fetch('/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.43.0",
|
"framer-motion": "^12.23.24",
|
||||||
"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,9 +3,4 @@ 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,4 +1,3 @@
|
|||||||
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';
|
||||||
@@ -70,7 +69,7 @@ export const detectFields = async ({
|
|||||||
onError,
|
onError,
|
||||||
signal,
|
signal,
|
||||||
}: DetectFieldsOptions): Promise<void> => {
|
}: DetectFieldsOptions): Promise<void> => {
|
||||||
const response = await fetch(formatPath('/api/ai/detect-fields'), {
|
const response = await fetch('/api/ai/detect-fields', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ export const detectRecipients = async ({
|
|||||||
onError,
|
onError,
|
||||||
signal,
|
signal,
|
||||||
}: DetectRecipientsOptions): Promise<void> => {
|
}: DetectRecipientsOptions): Promise<void> => {
|
||||||
const response = await fetch(formatPath('/api/ai/detect-recipients'), {
|
const response = await fetch('/api/ai/detect-recipients', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -14,22 +14,9 @@ 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,9 +46,7 @@ export interface HonoEnv {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePath = (env('NEXT_PUBLIC_BASE_PATH') ?? '').replace(/\/$/, '');
|
const app = new Hono<HonoEnv>();
|
||||||
|
|
||||||
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, formatPath } from '@documenso/lib/constants/app';
|
import { API_V2_BETA_URL, API_V2_URL } 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,10 +11,8 @@ 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,
|
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
|
||||||
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,4 +1,3 @@
|
|||||||
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';
|
||||||
@@ -6,14 +5,10 @@ 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: formatPath('/api/trpc'),
|
endpoint: '/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,10 +23,6 @@ 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,12 +50,6 @@ 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"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- documenso_database:/var/lib/postgresql/data
|
- documenso_database:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=password
|
- POSTGRES_PASSWORD=password
|
||||||
- POSTGRES_DB=documenso
|
- POSTGRES_DB=documenso
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
test: ['CMD-SHELL', 'pg_isready -U documenso']
|
||||||
interval: 1s
|
interval: 1s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
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.43.0",
|
"framer-motion": "^12.23.24",
|
||||||
"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.43.0",
|
"version": "12.23.24",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||||
"integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==",
|
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "^12.43.0",
|
"motion-dom": "^12.23.23",
|
||||||
"motion-utils": "^12.39.0",
|
"motion-utils": "^12.23.6",
|
||||||
"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.43.0",
|
"version": "12.23.23",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||||
"integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==",
|
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "^12.39.0"
|
"motion-utils": "^12.23.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-utils": {
|
"node_modules/motion-utils": {
|
||||||
"version": "12.39.0",
|
"version": "12.23.6",
|
||||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||||
"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.43.0",
|
"framer-motion": "^12.23.24",
|
||||||
"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 { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { 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,6 +36,8 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -43,7 +45,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 ?? formatPath('/signin');
|
window.location.href = redirectPath ?? this.signOutredirectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async signOutAllSessions() {
|
public async signOutAllSessions() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { 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(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
const errorUrl = new URL('/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(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
const errorUrl = new URL('/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(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||||
|
|
||||||
@@ -213,18 +213,15 @@ 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 = defaultRedirectPath;
|
redirectPath = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidReturnTo(redirectPath)) {
|
if (!isValidReturnTo(redirectPath)) {
|
||||||
redirectPath = defaultRedirectPath;
|
redirectPath = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
|
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||||
|
|
||||||
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
|
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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';
|
||||||
@@ -57,7 +56,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(formatPath(`/o/${orgUrl}`), 302);
|
return c.redirect(`/o/${orgUrl}`, 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
let userToLink = await prisma.user.findFirst({
|
let userToLink = await prisma.user.findFirst({
|
||||||
|
|||||||
@@ -1,25 +1,5 @@
|
|||||||
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.
|
||||||
*/
|
*/
|
||||||
@@ -30,20 +10,19 @@ 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 !== getWebAppOrigin()) {
|
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||||
window.location.href = getDefaultRedirect();
|
window.location.href = '/';
|
||||||
} else {
|
} else {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSignInRedirect = (redirectUrl?: string) => {
|
export const handleSignInRedirect = (redirectUrl: string = '/') => {
|
||||||
const target = redirectUrl ?? getDefaultRedirect();
|
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||||
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
|
|
||||||
|
|
||||||
if (url.origin !== getWebAppOrigin()) {
|
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||||
window.location.href = getDefaultRedirect();
|
window.location.href = '/';
|
||||||
} else {
|
} else {
|
||||||
window.location.href = target;
|
window.location.href = redirectUrl;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ export type GetLimitsOptions = {
|
|||||||
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
||||||
const requestHeaders = headers ?? {};
|
const requestHeaders = headers ?? {};
|
||||||
|
|
||||||
// Note: the path must be appended rather than passed as the `new URL()` path
|
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
|
||||||
// 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,42 +6,6 @@ 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();
|
||||||
|
|
||||||
@@ -53,14 +17,6 @@ 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';
|
||||||
|
|
||||||
@@ -143,5 +99,3 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
|
|||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSigna
|
|||||||
let imageWidth = image.width;
|
let imageWidth = image.width;
|
||||||
let imageHeight = image.height;
|
let imageHeight = image.height;
|
||||||
|
|
||||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight);
|
||||||
|
|
||||||
imageWidth = imageWidth * scalingFactor;
|
imageWidth = imageWidth * scalingFactor;
|
||||||
imageHeight = imageHeight * scalingFactor;
|
imageHeight = imageHeight * scalingFactor;
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
|
|||||||
let imageWidth = image.width;
|
let imageWidth = image.width;
|
||||||
let imageHeight = image.height;
|
let imageHeight = image.height;
|
||||||
|
|
||||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight);
|
||||||
|
|
||||||
imageWidth = imageWidth * scalingFactor;
|
imageWidth = imageWidth * scalingFactor;
|
||||||
imageHeight = imageHeight * scalingFactor;
|
imageHeight = imageHeight * scalingFactor;
|
||||||
|
|||||||
+147
-147
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig
|
|||||||
let imageWidth = img.width;
|
let imageWidth = img.width;
|
||||||
let imageHeight = img.height;
|
let imageHeight = img.height;
|
||||||
|
|
||||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight);
|
||||||
|
|
||||||
imageWidth = imageWidth * scalingFactor;
|
imageWidth = imageWidth * scalingFactor;
|
||||||
imageHeight = imageHeight * scalingFactor;
|
imageHeight = imageHeight * scalingFactor;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL, getBasePath } from '../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } 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 getBasePath();
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
|
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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;
|
||||||
@@ -38,7 +36,7 @@ const getFileFromBytes64 = (data: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFileFromS3 = async (key: string) => {
|
const getFileFromS3 = async (key: string) => {
|
||||||
const getPresignedUrlResponse = await fetch(formatPath('/api/files/presigned-get-url'), {
|
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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 = {
|
||||||
@@ -39,7 +38,7 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
|||||||
|
|
||||||
formData.append('file', properFile);
|
formData.append('file', properFile);
|
||||||
|
|
||||||
const response = await fetch(formatPath('/api/files/upload-pdf'), {
|
const response = await fetch('/api/files/upload-pdf', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildUploadAuthHeaders(options),
|
headers: buildUploadAuthHeaders(options),
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
import { getBasePath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { 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) {
|
||||||
@@ -21,10 +10,7 @@ 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());
|
||||||
|
|
||||||
// Compare against the origin, not the raw env value: when the app is served
|
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +30,7 @@ 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());
|
||||||
|
|
||||||
const basePath = getBasePath();
|
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
||||||
|
|
||||||
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, IS_DOCUMENSO_CLOUD } from '../constants/app';
|
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||||
import { canExecuteOrganisationAction } from './organisations';
|
import { canExecuteOrganisationAction } from './organisations';
|
||||||
import { canExecuteTeamAction } from './teams';
|
import { canExecuteTeamAction } from './teams';
|
||||||
|
|
||||||
@@ -73,7 +73,6 @@ 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);
|
||||||
@@ -127,7 +126,7 @@ export const getSettingsNavGroups = ({
|
|||||||
label: msg`Certificates`,
|
label: msg`Certificates`,
|
||||||
isSubNav: true,
|
isSubNav: true,
|
||||||
},
|
},
|
||||||
...((isBillingEnabled && organisation.organisationClaim.flags.emailDomains) || isDocumensoCloud
|
...(isBillingEnabled && organisation.organisationClaim.flags.emailDomains
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'email-domains',
|
key: 'email-domains',
|
||||||
@@ -155,7 +154,7 @@ export const getSettingsNavGroups = ({
|
|||||||
label: msg`Groups`,
|
label: msg`Groups`,
|
||||||
icon: GroupIcon,
|
icon: GroupIcon,
|
||||||
},
|
},
|
||||||
...((isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal) || isDocumensoCloud
|
...(isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'sso',
|
key: 'sso',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
|
|||||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import { match, P } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
@@ -149,11 +148,19 @@ export const createEnvelopeRouteCaller = async ({
|
|||||||
accessAuth: recipient.accessAuth,
|
accessAuth: recipient.accessAuth,
|
||||||
actionAuth: recipient.actionAuth,
|
actionAuth: recipient.actionAuth,
|
||||||
fields: recipient.fields?.map((field) => {
|
fields: recipient.fields?.map((field) => {
|
||||||
const documentDataId = match(field.identifier)
|
let documentDataId: string | undefined;
|
||||||
.with(P.string, (title) => envelopeItems.find((item) => item.title === title)?.documentDataId)
|
|
||||||
.with(P.number, (index) => envelopeItems.at(index)?.documentDataId)
|
if (typeof field.identifier === 'string') {
|
||||||
.with(undefined, () => envelopeItems.at(0)?.documentDataId)
|
documentDataId = envelopeItems.find((item) => item.title === field.identifier)?.documentDataId;
|
||||||
.exhaustive();
|
}
|
||||||
|
|
||||||
|
if (typeof field.identifier === 'number') {
|
||||||
|
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.identifier === undefined) {
|
||||||
|
documentDataId = envelopeItems.at(0)?.documentDataId;
|
||||||
|
}
|
||||||
|
|
||||||
if (!documentDataId) {
|
if (!documentDataId) {
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export const signEnvelopeFieldRoute = procedure
|
|||||||
const field = await prisma.field.findFirst({
|
const field = await prisma.field.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
recipient:
|
recipient: {
|
||||||
recipient.role === RecipientRole.ASSISTANT
|
...(recipient.role === RecipientRole.ASSISTANT
|
||||||
? {
|
? {
|
||||||
signingStatus: {
|
signingStatus: {
|
||||||
not: SigningStatus.SIGNED,
|
not: SigningStatus.SIGNED,
|
||||||
@@ -52,7 +52,8 @@ export const signEnvelopeFieldRoute = procedure
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
id: recipient.id,
|
id: recipient.id,
|
||||||
},
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
envelope: {
|
envelope: {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
|
|||||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { match, P } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import { useEnvelopeMeta, ZUseEnvelopeRequestSchema, ZUseEnvelopeResponseSchema } from './use-envelope.types';
|
import { useEnvelopeMeta, ZUseEnvelopeRequestSchema, ZUseEnvelopeResponseSchema } from './use-envelope.types';
|
||||||
@@ -88,11 +87,20 @@ export const useEnvelopeRoute = authenticatedProcedure
|
|||||||
|
|
||||||
// Map custom document data using identifiers
|
// Map custom document data using identifiers
|
||||||
const customDocumentDataMapped = customDocumentData?.map((mapping) => {
|
const customDocumentDataMapped = customDocumentData?.map((mapping) => {
|
||||||
|
let documentDataId: string | undefined;
|
||||||
|
|
||||||
// Find the uploaded file by identifier
|
// Find the uploaded file by identifier
|
||||||
const documentDataId = match(mapping.identifier)
|
if (typeof mapping.identifier === 'string') {
|
||||||
.with(P.string, (name) => uploadedFiles.find((file) => file.name === name)?.documentDataId)
|
documentDataId = uploadedFiles.find((file) => file.name === mapping.identifier)?.documentDataId;
|
||||||
.with(P.number, (index) => uploadedFiles.at(index)?.documentDataId)
|
}
|
||||||
.exhaustive();
|
|
||||||
|
if (typeof mapping.identifier === 'number') {
|
||||||
|
documentDataId = uploadedFiles.at(mapping.identifier)?.documentDataId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping.identifier === undefined) {
|
||||||
|
documentDataId = uploadedFiles.at(0)?.documentDataId;
|
||||||
|
}
|
||||||
|
|
||||||
if (!documentDataId) {
|
if (!documentDataId) {
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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';
|
||||||
@@ -24,7 +23,7 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
|
|||||||
|
|
||||||
formData.append('lang', lang);
|
formData.append('lang', lang);
|
||||||
|
|
||||||
await fetch(formatPath('/api/locale'), {
|
await fetch('/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.43.0",
|
"framer-motion": "^12.23.24",
|
||||||
"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