From 617f8cc204d73a93290085350bc284157c0aef44 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 12 Aug 2026 17:33:08 +1000 Subject: [PATCH] feat: show feature gated setting pages (#3167) --- .../dialogs/organisation-create-dialog.tsx | 4 +- .../settings-upsell/branding-upsell.tsx | 195 +++++++++++++ .../settings-upsell/email-domains-upsell.tsx | 213 ++++++++++++++ .../general/settings-upsell/motion.ts | 9 + .../settings-upsell/settings-upsell-card.tsx | 118 ++++++++ .../settings-upsell/sso-portal-upsell.tsx | 260 ++++++++++++++++++ .../settings-upsell/use-timed-cycle.ts | 47 ++++ .../o.$orgUrl.settings.branding.tsx | 16 +- .../o.$orgUrl.settings.email-domains.$id.tsx | 20 +- ....$orgUrl.settings.email-domains._index.tsx | 5 +- .../o.$orgUrl.settings.sso.tsx | 31 ++- .../t.$teamUrl+/settings.branding.tsx | 5 +- apps/remix/package.json | 2 +- package-lock.json | 28 +- packages/lib/constants/app.ts | 10 + packages/lib/utils/settings-nav.ts | 7 +- packages/ui/package.json | 2 +- 17 files changed, 940 insertions(+), 32 deletions(-) create mode 100644 apps/remix/app/components/general/settings-upsell/branding-upsell.tsx create mode 100644 apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx create mode 100644 apps/remix/app/components/general/settings-upsell/motion.ts create mode 100644 apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx create mode 100644 apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx create mode 100644 apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx index c76bc00a2..9bbec2636 100644 --- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -1,7 +1,7 @@ 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 { useSession } from '@documenso/lib/client-only/providers/session'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError } from '@documenso/lib/errors/app-error'; import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n'; @@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }: ))} diff --git a/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx b/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx new file mode 100644 index 000000000..f74432cf7 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/branding-upsell.tsx @@ -0,0 +1,195 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { Trans } from '@lingui/react/macro'; +import { motion, useReducedMotion } from 'framer-motion'; + +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +const DEMO_BRANDS = [ + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#A2E771', + ink: '#162C07', + tint: '#F2FBEA', + sheen: 'rgba(162, 231, 113, 0.32)', + }, + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#387BC7', + ink: '#ffffff', + tint: '#EDF3FA', + sheen: 'rgba(56, 123, 199, 0.28)', + }, + { + name: 'Documenso', + letter: 'D', + domain: 'noreply@app.documenso.com', + accent: '#9747F5', + ink: '#ffffff', + tint: '#F4EDFE', + sheen: 'rgba(151, 71, 245, 0.26)', + }, +]; + +/** + * Milliseconds each brand is shown before cycling to the next. + */ +const BRAND_CYCLE_INTERVAL_MS = 2400; + +export const BrandingUpsell = () => { + const organisation = useCurrentOrganisation(); + + const isReducedMotion = useReducedMotion(); + const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS)); + + const isStatic = isReducedMotion ?? false; + + const brand = DEMO_BRANDS[brandIndex]; + + return ( + Teams} + title={Unlock Branding Preferences} + description={ + Put your own brand on every document you send. Branding is available on the Teams plan and above. + } + features={[ + Your logo on signing pages and emails, + Company details and website in email footers, + Separate branding per team, + ]} + preview={ +
+
+ + Brand accent + + +
+ {DEMO_BRANDS.map((dotBrand, index) => ( + + ))} +
+
+ +
+ + {/* The sender identity never changes — only the tile colours tween per brand. */} + + {brand.letter} + + + {/* + * 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). + */} +
+
+

{brand.name}

+
+ +
+

{brand.domain}

+
+
+
+ +
+

+ Please sign: Example.pdf +

+ +

+ {organisation.name} has invited you to sign this document. +

+ + {/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */} + + + Sign + + +
+ +
+ + Company details + + + + + +
+ + {/* + * 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. + */} + +
+
+ } + /> + ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx b/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx new file mode 100644 index 000000000..183ad3b80 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx @@ -0,0 +1,213 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import { Trans } from '@lingui/react/macro'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { BadgeCheckIcon, MailIcon } from 'lucide-react'; +import { BrandingLogoIcon } from '../branding-logo-icon'; +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +/** + * Named sender identities cycled through while the preview is in its branded + * state — one per branded cycle step, shown as the sender name and address. + */ +const BRANDED_SENDERS = [ + { name: 'Support', email: 'support@example.com' }, + { name: 'Team', email: 'hello@example.com' }, + { name: 'Sales', email: 'sales@example.com' }, + { name: 'Example', email: 'noreply@example.com' }, +]; + +/** + * How long the initial unbranded (Documenso default) state is shown before + * the first flip starts. Shown exactly once — the cycle never returns to it. + */ +const INITIAL_STATE_DURATION_MS = 2500; + +/** + * How long each branded sender identity is shown before cycling to the next, + * giving the viewer time to read the changed address. + */ +const BRANDED_STATE_DURATION_MS = 5000; + +/** + * One duration per cycle step: the unbranded state first, then one step per + * named sender identity, derived from the identity count so the two cannot + * drift. + */ +const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)]; + +export const EmailDomainsUpsell = () => { + const organisation = useCurrentOrganisation(); + + const isReducedMotion = useReducedMotion(); + + // Loop from index 1: the unbranded Documenso intro plays exactly once, + // then the cycle rotates through the branded senders only. + const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1); + + const isBranded = cycleIndex > 0; + const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0]; + + const isStatic = isReducedMotion ?? false; + + return ( + Enterprise} + title={Unlock Email Domains} + description={ + Send documents from your own domain. Email domains are available on the Enterprise plan. + } + features={[ + Send emails to recipients from your domain, + Easy DNS setup with auto-generated DKIM and SPF records, + Named senders with defaults per team, template or document, + ]} + ctaLabel={Contact Sales} + ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL} + ctaExternal + preview={ +
+
+ + + {isBranded ? ( + + ) : ( + + )} + + + {isBranded ? Sending from your domain : Sending from app.documenso.com} + + + +
+ +
+
+
+ + + {/* + * 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. + */} + + {isBranded ? ( + + {organisation.avatarImageId && ( + + )} + {brandedSender.name[0]} + + ) : ( + + )} + + + +
+ +
+
+ + + {isBranded ? brandedSender.name : 'Documenso'} + + {/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */} + {isBranded && ( + + + + )} + + +
+ +
+ + + {isBranded ? brandedSender.email : 'noreply@app.documenso.com'} + + +
+
+
+ +
+

+ Please sign: Example.pdf +

+ +

+ {organisation.name} has invited you to sign this document. +

+
+ + {/* + * 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. + */} + +
+
+ } + /> + ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/motion.ts b/apps/remix/app/components/general/settings-upsell/motion.ts new file mode 100644 index 000000000..da8ee359f --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/motion.ts @@ -0,0 +1,9 @@ +/** + * Shared motion vocabulary for the settings upsell previews. Values ported + * from the design prototype (`design/SSO Upsell.dc.html`). + */ +export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const; + +export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const; + +export const EASE = [0.22, 0.61, 0.36, 1] as const; diff --git a/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx b/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx new file mode 100644 index 000000000..054ef875f --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx @@ -0,0 +1,118 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans } from '@lingui/react/macro'; +import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; + +export type SettingsUpsellCardProps = { + planLabel: ReactNode; + title: ReactNode; + description: ReactNode; + features: ReactNode[]; + preview: ReactNode; + + /** + * CTA label. Defaults to "Upgrade Plan". + */ + ctaLabel?: ReactNode; + + /** + * CTA destination. Defaults to the organisation billing settings page. + */ + ctaTo?: string; + + /** + * Render the CTA as an external link (new tab) instead of an internal route. + */ + ctaExternal?: boolean; +}; + +/** + * Shared split-card layout for claim-gated settings upsells on Documenso + * Cloud. The left pane pitches the feature (plan badge, title, description, + * feature list, upgrade CTA); the right pane renders a decorative scenario + * preview supplied by the caller. + * + * Callers decide *when* to render this (cloud + missing claim flag). + */ +export const SettingsUpsellCard = ({ + planLabel, + title, + description, + features, + preview, + ctaLabel, + ctaTo, + ctaExternal = false, +}: SettingsUpsellCardProps) => { + const organisation = useCurrentOrganisation(); + + const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole); + + const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`; + + const ctaContent = ( + <> + {ctaLabel ?? Upgrade Plan} + + + ); + + return ( +
+ {/* + * `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). + */} +
+ + + {planLabel} + + +

{title}

+ +

{description}

+ +
    + {features.map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ + {canManageBilling ? ( + + ) : ( +

+ Contact your organisation owner to upgrade plans. +

+ )} +
+ + +
+ ); +}; diff --git a/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx b/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx new file mode 100644 index 000000000..1444c1ea1 --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx @@ -0,0 +1,260 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app'; +import { Trans } from '@lingui/react/macro'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { FingerprintIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { EASE, POP, SPRING } from './motion'; +import { SettingsUpsellCard } from './settings-upsell-card'; +import { useTimedCycle } from './use-timed-cycle'; + +export const SsoPortalUpsell = () => { + const isReducedMotion = useReducedMotion(); + const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS); + + return ( + Enterprise} + title={Unlock the Organisation SSO Portal} + description={ + + Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan. + + } + features={[ + Works with any OIDC provider — Okta, Entra ID, Google and more, + Accounts are automatically added to your organisation on sign-in, + Restrict sign-ins by email domain and choose the default role, + ]} + ctaLabel={Contact Sales} + ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL} + ctaExternal + preview={ +
+
+ + {sceneIndex === 0 && } + {sceneIndex === 1 && } + {sceneIndex === 2 && } + +
+
+ } + /> + ); +}; + +/** + * Absolute-positioned panel each scene renders in, handling the shared + * slide-and-fade transition between scenes. + */ +const ScenePanel = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +/** + * 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 ( + + +
+ {([...organisation.name][0] ?? 'D').toUpperCase()} +
+
+ + + Welcome to {organisation.name} + + + + Single sign-on + + +
+ + + Continue with SSO + + + + + + + + +
+
+ ); +}; + +/** + * Scene 2: redirecting to the identity provider, with a rotating ring around + * a fingerprint tile and a filling progress bar. + */ +const RedirectScene = () => { + return ( + +
+ + +
+ +
+
+ + + Redirecting to your identity provider + + +
+ +
+
+ ); +}; + +/** + * 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 ( + +
+ + + + + + + +
+ + + Signed in + + + + {user.email} + +
+ ); +}; + +/** + * Milliseconds each scene is shown before advancing: portal, redirect, + * success. + */ +const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000]; diff --git a/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts b/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts new file mode 100644 index 000000000..57e9b952d --- /dev/null +++ b/apps/remix/app/components/general/settings-upsell/use-timed-cycle.ts @@ -0,0 +1,47 @@ +import { useReducedMotion } from 'framer-motion'; +import { useEffect, useState } from 'react'; + +/** + * Cycles an index through `durations.length` steps, waiting `durations[i]` + * milliseconds on step `i` before advancing to the next. + * + * When the cycle wraps past the last step it continues from `loopStartIndex` + * (default `0`), letting consumers play intro-only steps exactly once and + * then loop through the remaining steps forever. + * + * Under `prefers-reduced-motion` the cycle never starts and the index stays + * at 0, so consumers render their initial state statically. + * + * Pass module-level constants for `durations` and `loopStartIndex` — their + * identities are intentionally not dependencies. + */ +export const useTimedCycle = (durations: number[], loopStartIndex = 0) => { + const [index, setIndex] = useState(0); + + const isReducedMotion = useReducedMotion(); + + useEffect(() => { + if (isReducedMotion || durations.length === 0) { + setIndex(0); + return; + } + + let current = 0; + let timeout: ReturnType; + + 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; +}; diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx index 9def0b934..6c1dbb8e1 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx @@ -1,5 +1,5 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import { trpc } from '@documenso/trpc/react'; @@ -17,6 +17,7 @@ import { type TBrandingPreferencesFormSchema, } from '~/components/forms/branding-preferences-form'; import { SettingsHeader } from '~/components/general/settings-header'; +import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell'; import { useOptionalCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -121,11 +122,18 @@ export default function OrganisationSettingsBrandingPage() { ? t`Here you can set branding preferences for your team.` : t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`; + const brandingPreferencesFormEnabled = + organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED(); + return (
- + - {organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? ( + {brandingPreferencesFormEnabled ? (
)}
+ ) : IS_DOCUMENSO_CLOUD() ? ( + ) : (
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx index a433f4f49..37e0fc4fc 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx @@ -1,5 +1,5 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app'; import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains'; import { trpc } from '@documenso/trpc/react'; import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types'; @@ -27,8 +27,9 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { SettingsHeader } from '~/components/general/settings-header'; +import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell'; -import type { Route } from './+types/o.$orgUrl.settings.groups.$id'; +import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id'; export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) { const { t } = useLingui(); @@ -96,10 +97,23 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co ] satisfies DataTableColumnDef[]; }, [organisation]); + const pageHeader = t`Email Domain Settings`; + const pageSubtitle = t`Manage your email domain settings.`; + if (!IS_BILLING_ENABLED()) { return null; } + if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) { + return ( +
+ + + +
+ ); + } + if (isLoadingEmailDomain) { return ; } @@ -132,7 +146,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co return (
- + diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx index 54c1a11a9..5a8f5a089 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx @@ -1,5 +1,5 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; @@ -9,6 +9,7 @@ import { Link } from 'react-router'; import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog'; import { 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 { appMetaTags } from '~/utils/meta'; @@ -41,6 +42,8 @@ export default function OrganisationSettingsEmailDomains() {
+ ) : IS_DOCUMENSO_CLOUD() ? ( + ) : (
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx index 7c682c496..1536c1a1b 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx @@ -1,4 +1,5 @@ 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_MAP } from '@documenso/lib/constants/organisations-translations'; import { @@ -28,6 +29,7 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { SettingsHeader } from '~/components/general/settings-header'; +import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell'; import { appMetaTags } from '~/utils/meta'; const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data @@ -63,10 +65,33 @@ export default function OrganisationSettingSSOLoginPage() { const { t } = useLingui(); const organisation = useCurrentOrganisation(); + const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true; + const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } = - trpc.enterprise.organisation.authenticationPortal.get.useQuery({ - organisationId: organisation.id, - }); + trpc.enterprise.organisation.authenticationPortal.get.useQuery( + { + 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 ( +
+ + + +
+ ); + } if (isLoadingAuthenticationPortal || !authenticationPortal) { return ; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx index 7e0366d6f..a46b07056 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx @@ -1,5 +1,5 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import { trpc } from '@documenso/trpc/react'; @@ -17,6 +17,7 @@ import { type TBrandingPreferencesFormSchema, } from '~/components/forms/branding-preferences-form'; import { SettingsHeader } from '~/components/general/settings-header'; +import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell'; import { useCurrentTeam } from '~/providers/team'; export default function TeamsSettingsPage() { @@ -155,6 +156,8 @@ export default function TeamsSettingsPage() { )} + ) : IS_DOCUMENSO_CLOUD() ? ( + ) : (
diff --git a/apps/remix/package.json b/apps/remix/package.json index 0a889ccef..daa376df3 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -44,7 +44,7 @@ "autoprefixer": "^10.4.22", "colord": "^2.9.3", "content-disposition": "^1.0.1", - "framer-motion": "^12.23.24", + "framer-motion": "^12.43.0", "hono": "^4.12.14", "hono-react-router-adapter": "^0.6.5", "input-otp": "^1.4.2", diff --git a/package-lock.json b/package-lock.json index 3510fd7a7..a10151db2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -399,7 +399,7 @@ "autoprefixer": "^10.4.22", "colord": "^2.9.3", "content-disposition": "^1.0.1", - "framer-motion": "^12.23.24", + "framer-motion": "^12.43.0", "hono": "^4.12.14", "hono-react-router-adapter": "^0.6.5", "input-otp": "^1.4.2", @@ -20310,13 +20310,13 @@ } }, "node_modules/framer-motion": { - "version": "12.23.24", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", - "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.23", - "motion-utils": "^12.23.6", + "motion-dom": "^12.43.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -24677,18 +24677,18 @@ "license": "MIT" }, "node_modules/motion-dom": { - "version": "12.23.23", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", - "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", "dependencies": { - "motion-utils": "^12.23.6" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.23.6", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", - "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, "node_modules/mqtt": { @@ -33933,7 +33933,7 @@ "clsx": "^1.2.1", "cmdk": "^0.2.1", "colord": "^2.9.3", - "framer-motion": "^12.23.24", + "framer-motion": "^12.43.0", "lucide-react": "^0.554.0", "luxon": "^3.7.2", "pdfjs-dist": "5.4.296", diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index 67931b4c5..9740fae22 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -53,6 +53,14 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () => 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_URL = '/api/v2'; @@ -135,3 +143,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => { return value; }; + +export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta'; diff --git a/packages/lib/utils/settings-nav.ts b/packages/lib/utils/settings-nav.ts index 7f5f51330..c503c9b53 100644 --- a/packages/lib/utils/settings-nav.ts +++ b/packages/lib/utils/settings-nav.ts @@ -18,7 +18,7 @@ import { } from 'lucide-react'; import type { ComponentType } from 'react'; import { FaUsers } from 'react-icons/fa6'; -import { IS_BILLING_ENABLED } from '../constants/app'; +import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '../constants/app'; import { canExecuteOrganisationAction } from './organisations'; import { canExecuteTeamAction } from './teams'; @@ -73,6 +73,7 @@ export const getSettingsNavGroups = ({ hasManageableBillingOrgs, }: GetSettingsNavGroupsArgs): SettingsNavGroups => { const isBillingEnabled = IS_BILLING_ENABLED(); + const isDocumensoCloud = IS_DOCUMENSO_CLOUD(); const canManageOrg = organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole); @@ -126,7 +127,7 @@ export const getSettingsNavGroups = ({ label: msg`Certificates`, isSubNav: true, }, - ...(isBillingEnabled && organisation.organisationClaim.flags.emailDomains + ...((isBillingEnabled && organisation.organisationClaim.flags.emailDomains) || isDocumensoCloud ? [ { key: 'email-domains', @@ -154,7 +155,7 @@ export const getSettingsNavGroups = ({ label: msg`Groups`, icon: GroupIcon, }, - ...(isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal + ...((isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal) || isDocumensoCloud ? [ { key: 'sso', diff --git a/packages/ui/package.json b/packages/ui/package.json index d39030cbc..85472a456 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -62,7 +62,7 @@ "clsx": "^1.2.1", "cmdk": "^0.2.1", "colord": "^2.9.3", - "framer-motion": "^12.23.24", + "framer-motion": "^12.43.0", "lucide-react": "^0.554.0", "luxon": "^3.7.2", "pdfjs-dist": "5.4.296",