mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
feat: show feature gated setting pages (#3167)
This commit is contained in:
@@ -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 }:
|
||||
))}
|
||||
|
||||
<Link
|
||||
to="https://documen.so/enterprise-cta"
|
||||
to={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
target="_blank"
|
||||
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
const DEMO_BRANDS = [
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#A2E771',
|
||||
ink: '#162C07',
|
||||
tint: '#F2FBEA',
|
||||
sheen: 'rgba(162, 231, 113, 0.32)',
|
||||
},
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#387BC7',
|
||||
ink: '#ffffff',
|
||||
tint: '#EDF3FA',
|
||||
sheen: 'rgba(56, 123, 199, 0.28)',
|
||||
},
|
||||
{
|
||||
name: 'Documenso',
|
||||
letter: 'D',
|
||||
domain: 'noreply@app.documenso.com',
|
||||
accent: '#9747F5',
|
||||
ink: '#ffffff',
|
||||
tint: '#F4EDFE',
|
||||
sheen: 'rgba(151, 71, 245, 0.26)',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Milliseconds each brand is shown before cycling to the next.
|
||||
*/
|
||||
const BRAND_CYCLE_INTERVAL_MS = 2400;
|
||||
|
||||
export const BrandingUpsell = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const brandIndex = useTimedCycle(DEMO_BRANDS.map(() => BRAND_CYCLE_INTERVAL_MS));
|
||||
|
||||
const isStatic = isReducedMotion ?? false;
|
||||
|
||||
const brand = DEMO_BRANDS[brandIndex];
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Teams</Trans>}
|
||||
title={<Trans>Unlock Branding Preferences</Trans>}
|
||||
description={
|
||||
<Trans>Put your own brand on every document you send. Branding is available on the Teams plan and above.</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="logo">Your logo on signing pages and emails</Trans>,
|
||||
<Trans key="details">Company details and website in email footers</Trans>,
|
||||
<Trans key="teams">Separate branding per team</Trans>,
|
||||
]}
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="flex h-8 items-center justify-between px-1">
|
||||
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
<Trans>Brand accent</Trans>
|
||||
</span>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{DEMO_BRANDS.map((dotBrand, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{
|
||||
scale: index === brandIndex ? 1.25 : 1,
|
||||
opacity: index === brandIndex ? 1 : 0.42,
|
||||
boxShadow:
|
||||
index === brandIndex ? '0 0 0 3px rgba(15, 23, 42, 0.08)' : '0 0 0 0 rgba(15, 23, 42, 0)',
|
||||
}}
|
||||
transition={SPRING}
|
||||
className="h-[13px] w-[13px] rounded-full"
|
||||
style={{ backgroundColor: dotBrand.accent }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-3 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.tint }}
|
||||
transition={{ duration: 0.45, ease: EASE }}
|
||||
className="flex items-center gap-2.5 border-b px-4 py-3"
|
||||
>
|
||||
{/* The sender identity never changes — only the tile colours tween per brand. */}
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg font-semibold text-sm"
|
||||
>
|
||||
{brand.letter}
|
||||
</motion.div>
|
||||
|
||||
{/*
|
||||
* Hardcoded inks (not theme tokens): this row sits on the
|
||||
* hardcoded light `tint` band, so it pairs with hardcoded ink
|
||||
* colours the same way the email sibling pairs its hardcoded
|
||||
* avatar surfaces (hardcoded surface => hardcoded ink).
|
||||
*/}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-[#0f172a] text-sm">
|
||||
<p className="truncate">{brand.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-[#64748b] text-xs">
|
||||
<p className="truncate">{brand.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Please sign: Example.pdf</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||
</p>
|
||||
|
||||
{/* Same replay split as the logo tile: colours tween on the persistent button, the pop replays per brand on the remounting label. */}
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent, color: brand.ink }}
|
||||
transition={{ backgroundColor: { duration: 0.4 }, color: { duration: 0.4 } }}
|
||||
className="mt-3 inline-block rounded-md px-3 py-1.5 font-medium text-xs"
|
||||
>
|
||||
<motion.span
|
||||
key={brandIndex}
|
||||
initial={isStatic ? false : { scale: 0.96 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ ...POP, delay: 0.06 }}
|
||||
className="inline-block"
|
||||
>
|
||||
<Trans>Sign</Trans>
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2.5 border-t bg-muted px-4 py-2.5">
|
||||
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
<Trans>Company details</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="h-1.5 w-[54px] rounded-full"
|
||||
style={{ opacity: 0.45 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : undefined}
|
||||
animate={{ backgroundColor: brand.accent }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="h-1.5 w-[34px] rounded-full"
|
||||
style={{ opacity: 0.22 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Keyed remount replays the sweep per brand. No opacity envelope —
|
||||
* keyframe arrays are unreliable on strict-mode remounts; both
|
||||
* endpoints sit outside the overflow-hidden card, so the clip
|
||||
* provides the fade in/out instead.
|
||||
*/}
|
||||
<motion.div
|
||||
key={`sheen-${brandIndex}`}
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '240%' }}
|
||||
transition={{ duration: 1.15, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||
style={{ background: `linear-gradient(105deg, transparent, ${brand.sheen}, transparent)` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { BadgeCheckIcon, MailIcon } from 'lucide-react';
|
||||
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
/**
|
||||
* Named sender identities cycled through while the preview is in its branded
|
||||
* state — one per branded cycle step, shown as the sender name and address.
|
||||
*/
|
||||
const BRANDED_SENDERS = [
|
||||
{ name: 'Support', email: 'support@example.com' },
|
||||
{ name: 'Team', email: 'hello@example.com' },
|
||||
{ name: 'Sales', email: 'sales@example.com' },
|
||||
{ name: 'Example', email: 'noreply@example.com' },
|
||||
];
|
||||
|
||||
/**
|
||||
* How long the initial unbranded (Documenso default) state is shown before
|
||||
* the first flip starts. Shown exactly once — the cycle never returns to it.
|
||||
*/
|
||||
const INITIAL_STATE_DURATION_MS = 2500;
|
||||
|
||||
/**
|
||||
* How long each branded sender identity is shown before cycling to the next,
|
||||
* giving the viewer time to read the changed address.
|
||||
*/
|
||||
const BRANDED_STATE_DURATION_MS = 5000;
|
||||
|
||||
/**
|
||||
* One duration per cycle step: the unbranded state first, then one step per
|
||||
* named sender identity, derived from the identity count so the two cannot
|
||||
* drift.
|
||||
*/
|
||||
const EMAIL_CYCLE_DURATIONS_MS = [INITIAL_STATE_DURATION_MS, ...BRANDED_SENDERS.map(() => BRANDED_STATE_DURATION_MS)];
|
||||
|
||||
export const EmailDomainsUpsell = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
|
||||
// Loop from index 1: the unbranded Documenso intro plays exactly once,
|
||||
// then the cycle rotates through the branded senders only.
|
||||
const cycleIndex = useTimedCycle(EMAIL_CYCLE_DURATIONS_MS, 1);
|
||||
|
||||
const isBranded = cycleIndex > 0;
|
||||
const brandedSender = BRANDED_SENDERS[cycleIndex - 1] ?? BRANDED_SENDERS[0];
|
||||
|
||||
const isStatic = isReducedMotion ?? false;
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Enterprise</Trans>}
|
||||
title={<Trans>Unlock Email Domains</Trans>}
|
||||
description={
|
||||
<Trans>Send documents from your own domain. Email domains are available on the Enterprise plan.</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="journey">Send emails to recipients from your domain</Trans>,
|
||||
<Trans key="dns">Easy DNS setup with auto-generated DKIM and SPF records</Trans>,
|
||||
<Trans key="senders">Named senders with defaults per team, template or document</Trans>,
|
||||
]}
|
||||
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
ctaExternal
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="relative h-8">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isBranded ? 'chip-on' : 'chip-off'}
|
||||
initial={isStatic ? false : { opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={SPRING}
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center gap-2 rounded-full border bg-background px-3 font-mono text-xs',
|
||||
isBranded ? 'border-documenso-300 text-documenso-800' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isBranded ? (
|
||||
<BadgeCheckIcon className="h-3.5 w-3.5 shrink-0 text-documenso-700" />
|
||||
) : (
|
||||
<MailIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className="truncate">
|
||||
{isBranded ? <Trans>Sending from your domain</Trans> : <Trans>Sending from app.documenso.com</Trans>}
|
||||
</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b p-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-semibold text-sm">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={`logo-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{/*
|
||||
* Remounts with its keyed parent on every cycle step so the
|
||||
* pop replays each change. Single-value spring (POP
|
||||
* overshoots past 1) instead of scale keyframes — keyframe
|
||||
* arrays are unreliable on strict-mode remounts.
|
||||
*/}
|
||||
<motion.span
|
||||
initial={isStatic ? false : { scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ scale: POP }}
|
||||
className="inline-block"
|
||||
>
|
||||
{isBranded ? (
|
||||
<Avatar className="h-8 w-8 border border-solid">
|
||||
{organisation.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(organisation.avatarImageId)} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm">{brandedSender.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<BrandingLogoIcon className="h-8 w-8" />
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`name-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.28, ease: EASE }}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate">{isBranded ? brandedSender.name : 'Documenso'}</span>
|
||||
|
||||
{/* Inside the keyed row so it exits with the name and pops back in on every cycle step. */}
|
||||
{isBranded && (
|
||||
<motion.span
|
||||
initial={isStatic ? false : { scale: 0, rotate: -40 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ ...POP, delay: 0.12 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<BadgeCheckIcon className="h-3.5 w-3.5 text-documenso-700" />
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-muted-foreground text-xs">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`addr-${cycleIndex}`}
|
||||
initial={isStatic ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.28, ease: EASE }}
|
||||
className="truncate"
|
||||
>
|
||||
{isBranded ? brandedSender.email : 'noreply@app.documenso.com'}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Please sign: Example.pdf</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>{organisation.name} has invited you to sign this document.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Keyed remount replays the sweep on every cycle step. No opacity
|
||||
* envelope — keyframe arrays are unreliable on strict-mode
|
||||
* remounts; both endpoints sit outside the overflow-hidden card,
|
||||
* so the clip provides the fade in/out instead.
|
||||
*/}
|
||||
<motion.div
|
||||
key={`sheen-${cycleIndex}`}
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '240%' }}
|
||||
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-[55%]"
|
||||
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.32), transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Shared motion vocabulary for the settings upsell previews. Values ported
|
||||
* from the design prototype (`design/SSO Upsell.dc.html`).
|
||||
*/
|
||||
export const SPRING = { type: 'spring', stiffness: 280, damping: 22 } as const;
|
||||
|
||||
export const POP = { type: 'spring', stiffness: 420, damping: 16 } as const;
|
||||
|
||||
export const EASE = [0.22, 0.61, 0.36, 1] as const;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ArrowRightIcon, CheckIcon, LockIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export type SettingsUpsellCardProps = {
|
||||
planLabel: ReactNode;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
features: ReactNode[];
|
||||
preview: ReactNode;
|
||||
|
||||
/**
|
||||
* CTA label. Defaults to "Upgrade Plan".
|
||||
*/
|
||||
ctaLabel?: ReactNode;
|
||||
|
||||
/**
|
||||
* CTA destination. Defaults to the organisation billing settings page.
|
||||
*/
|
||||
ctaTo?: string;
|
||||
|
||||
/**
|
||||
* Render the CTA as an external link (new tab) instead of an internal route.
|
||||
*/
|
||||
ctaExternal?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared split-card layout for claim-gated settings upsells on Documenso
|
||||
* Cloud. The left pane pitches the feature (plan badge, title, description,
|
||||
* feature list, upgrade CTA); the right pane renders a decorative scenario
|
||||
* preview supplied by the caller.
|
||||
*
|
||||
* Callers decide *when* to render this (cloud + missing claim flag).
|
||||
*/
|
||||
export const SettingsUpsellCard = ({
|
||||
planLabel,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
preview,
|
||||
ctaLabel,
|
||||
ctaTo,
|
||||
ctaExternal = false,
|
||||
}: SettingsUpsellCardProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const canManageBilling = canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole);
|
||||
|
||||
const ctaHref = ctaTo ?? `/o/${organisation.url}/settings/billing`;
|
||||
|
||||
const ctaContent = (
|
||||
<>
|
||||
{ctaLabel ?? <Trans>Upgrade Plan</Trans>}
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8 overflow-hidden rounded-xl border-2 ring-4 ring-muted/70 md:grid md:grid-cols-[1.08fr_0.92fr] xl:-mx-8">
|
||||
{/*
|
||||
* `min-w-0` on both grid items: `fr` tracks have an `auto` content
|
||||
* minimum, so long preview content (e.g. a wide mono domain line) would
|
||||
* otherwise widen the right track beyond its 0.92fr share — and
|
||||
* re-balance the whole grid on every preview cycle (layout shift).
|
||||
*/}
|
||||
<div className="flex min-w-0 flex-col items-start p-6 md:p-8">
|
||||
<Badge size="small">
|
||||
<LockIcon className="mr-1 h-3 w-3" />
|
||||
<span className="uppercase">{planLabel}</span>
|
||||
</Badge>
|
||||
|
||||
<h3 className="mt-4 font-semibold text-xl">{title}</h3>
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-muted-foreground text-sm">{description}</p>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-documenso-200">
|
||||
<CheckIcon className="h-3 w-3 text-documenso-800" strokeWidth={2.5} />
|
||||
</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canManageBilling ? (
|
||||
<Button className="mt-8" asChild>
|
||||
{ctaExternal ? (
|
||||
<a href={ctaHref} target="_blank" rel="noreferrer">
|
||||
{ctaContent}
|
||||
</a>
|
||||
) : (
|
||||
<Link to={ctaHref}>{ctaContent}</Link>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-8 text-muted-foreground text-xs">
|
||||
<Trans>Contact your organisation owner to upgrade plans.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex min-w-0 flex-col justify-center gap-3 border-t bg-muted p-6 md:border-t-0 md:border-l md:p-8"
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL } from '@documenso/lib/constants/app';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { FingerprintIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { EASE, POP, SPRING } from './motion';
|
||||
import { SettingsUpsellCard } from './settings-upsell-card';
|
||||
import { useTimedCycle } from './use-timed-cycle';
|
||||
|
||||
export const SsoPortalUpsell = () => {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const sceneIndex = useTimedCycle(SSO_SCENE_DURATIONS_MS);
|
||||
|
||||
return (
|
||||
<SettingsUpsellCard
|
||||
planLabel={<Trans>Enterprise</Trans>}
|
||||
title={<Trans>Unlock the Organisation SSO Portal</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan.
|
||||
</Trans>
|
||||
}
|
||||
features={[
|
||||
<Trans key="oidc">Works with any OIDC provider — Okta, Entra ID, Google and more</Trans>,
|
||||
<Trans key="jit">Accounts are automatically added to your organisation on sign-in</Trans>,
|
||||
<Trans key="control">Restrict sign-ins by email domain and choose the default role</Trans>,
|
||||
]}
|
||||
ctaLabel={<Trans>Contact Sales</Trans>}
|
||||
ctaTo={DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL}
|
||||
ctaExternal
|
||||
preview={
|
||||
<div className="mx-auto w-full max-w-xs">
|
||||
<div className="relative h-[236px]">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{sceneIndex === 0 && <PortalScene key="portal" isStatic={isReducedMotion ?? false} />}
|
||||
{sceneIndex === 1 && <RedirectScene key="redirect" />}
|
||||
{sceneIndex === 2 && <SuccessScene key="success" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute-positioned panel each scene renders in, handling the shared
|
||||
* slide-and-fade transition between scenes.
|
||||
*/
|
||||
const ScenePanel = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||
transition={{ duration: 0.34, ease: EASE }}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-6 text-center shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 1: the organisation's SSO portal, with a timed faux press on the
|
||||
* "Continue with SSO" button (cursor flies in, button dips, sheen sweeps).
|
||||
*
|
||||
* When `isStatic` is set (reduced motion) every element renders with
|
||||
* `initial={false}`, skipping entrance and press animations.
|
||||
*/
|
||||
const PortalScene = ({ isStatic }: { isStatic: boolean }) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const rise = (delay: number) => ({
|
||||
initial: isStatic ? false : { y: 10, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
transition: { ...SPRING, delay },
|
||||
});
|
||||
|
||||
return (
|
||||
<ScenePanel>
|
||||
<motion.div
|
||||
initial={isStatic ? false : { scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ ...POP, delay: 0.04 }}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-documenso-200 font-semibold text-documenso-900">
|
||||
{([...organisation.name][0] ?? 'D').toUpperCase()}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p {...rise(0.12)} className="mt-3.5 font-semibold text-sm">
|
||||
<Trans>Welcome to {organisation.name}</Trans>
|
||||
</motion.p>
|
||||
|
||||
<motion.p {...rise(0.18)} className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>Single sign-on</Trans>
|
||||
</motion.p>
|
||||
|
||||
<div className="relative mt-4 w-full">
|
||||
<motion.div
|
||||
initial={isStatic ? false : { y: 10, opacity: 0, scale: 1 }}
|
||||
animate={{ y: 0, opacity: 1, scale: [1, 1, 0.955, 1] }}
|
||||
transition={{
|
||||
y: { ...SPRING, delay: 0.24 },
|
||||
opacity: { duration: 0.3, delay: 0.24 },
|
||||
scale: { duration: 2.6, times: [0, 0.63, 0.72, 0.84], ease: 'easeOut' },
|
||||
}}
|
||||
className="relative flex h-[38px] w-full items-center justify-center overflow-hidden rounded-md bg-foreground font-semibold text-background text-sm"
|
||||
>
|
||||
<span>
|
||||
<Trans>Continue with SSO</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={isStatic ? false : { x: '-130%' }}
|
||||
animate={{ x: '150%' }}
|
||||
transition={{ duration: 0.85, delay: 1.75, ease: 'easeOut' }}
|
||||
className="absolute top-0 bottom-0 left-[20%] w-3/5"
|
||||
style={{ background: 'linear-gradient(105deg, transparent, rgba(162, 231, 113, 0.45), transparent)' }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.svg
|
||||
initial={isStatic ? false : { x: 30, y: 30, opacity: 0, scale: 1 }}
|
||||
animate={{
|
||||
x: [30, 30, 0, 0, 0],
|
||||
y: [30, 30, 0, 0, 0],
|
||||
opacity: [0, 1, 1, 1, 0],
|
||||
scale: [1, 1, 1, 0.82, 1],
|
||||
}}
|
||||
transition={{ duration: 2.6, times: [0, 0.3, 0.63, 0.72, 0.94], ease: EASE }}
|
||||
width={17}
|
||||
height={17}
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.4}
|
||||
strokeLinejoin="round"
|
||||
className="absolute right-[26px] -bottom-2.5 fill-foreground stroke-background"
|
||||
>
|
||||
<path d="M4 2.5 19 12l-6.6 1.4L9.7 19.6z" />
|
||||
</motion.svg>
|
||||
</div>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 2: redirecting to the identity provider, with a rotating ring around
|
||||
* a fingerprint tile and a filling progress bar.
|
||||
*/
|
||||
const RedirectScene = () => {
|
||||
return (
|
||||
<ScenePanel>
|
||||
<div className="relative flex h-[46px] w-[46px] items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 0.95, repeat: Number.POSITIVE_INFINITY, ease: 'linear' }}
|
||||
className="absolute inset-0 rounded-full border-2"
|
||||
style={{ borderTopColor: '#A2E771' }}
|
||||
/>
|
||||
|
||||
<div className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-muted">
|
||||
<FingerprintIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.6} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 8, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.08 }}
|
||||
className="mt-3.5 max-w-[22ch] text-sm"
|
||||
>
|
||||
<Trans>Redirecting to your identity provider</Trans>
|
||||
</motion.p>
|
||||
|
||||
<div className="mt-4 h-1 w-[140px] overflow-hidden rounded-full bg-border">
|
||||
<motion.div
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: '100%' }}
|
||||
transition={{ duration: 1.35, ease: 'easeInOut' }}
|
||||
className="h-full rounded-full bg-documenso"
|
||||
/>
|
||||
</div>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene 3: signed in, with an expanding pulse ring, a popping green circle,
|
||||
* a drawn checkmark and the signed-in member's email.
|
||||
*/
|
||||
const SuccessScene = () => {
|
||||
const { user } = useSession();
|
||||
|
||||
return (
|
||||
<ScenePanel>
|
||||
<div className="relative h-10 w-10">
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0.85 }}
|
||||
animate={{ scale: 2.1, opacity: 0 }}
|
||||
transition={{ duration: 1.1, ease: 'easeOut' }}
|
||||
className="absolute inset-0 rounded-full border-2"
|
||||
style={{ borderColor: '#A2E771' }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.4, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={POP}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-documenso-200"
|
||||
>
|
||||
<svg
|
||||
width={19}
|
||||
height={19}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-documenso-900"
|
||||
>
|
||||
<motion.path
|
||||
d="M20 6 9 17l-5-5"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.12, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.16 }}
|
||||
className="mt-3.5 font-semibold text-sm"
|
||||
>
|
||||
<Trans>Signed in</Trans>
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.24 }}
|
||||
className="mt-1 font-mono text-muted-foreground text-xs"
|
||||
>
|
||||
{user.email}
|
||||
</motion.p>
|
||||
</ScenePanel>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Milliseconds each scene is shown before advancing: portal, redirect,
|
||||
* success.
|
||||
*/
|
||||
const SSO_SCENE_DURATIONS_MS = [3000, 2500, 3000];
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Cycles an index through `durations.length` steps, waiting `durations[i]`
|
||||
* milliseconds on step `i` before advancing to the next.
|
||||
*
|
||||
* When the cycle wraps past the last step it continues from `loopStartIndex`
|
||||
* (default `0`), letting consumers play intro-only steps exactly once and
|
||||
* then loop through the remaining steps forever.
|
||||
*
|
||||
* Under `prefers-reduced-motion` the cycle never starts and the index stays
|
||||
* at 0, so consumers render their initial state statically.
|
||||
*
|
||||
* Pass module-level constants for `durations` and `loopStartIndex` — their
|
||||
* identities are intentionally not dependencies.
|
||||
*/
|
||||
export const useTimedCycle = (durations: number[], loopStartIndex = 0) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const isReducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion || durations.length === 0) {
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let current = 0;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const tick = () => {
|
||||
const next = current + 1;
|
||||
|
||||
current = next >= durations.length ? Math.min(loopStartIndex, durations.length - 1) : next;
|
||||
|
||||
setIndex(current);
|
||||
timeout = setTimeout(tick, durations[current]);
|
||||
};
|
||||
|
||||
timeout = setTimeout(tick, durations[0]);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isReducedMotion]);
|
||||
|
||||
return index;
|
||||
};
|
||||
@@ -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 (
|
||||
<div>
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
<SettingsHeader
|
||||
title={settingsHeaderText}
|
||||
subtitle={settingsHeaderSubtitle}
|
||||
hideDivider={!brandingPreferencesFormEnabled}
|
||||
/>
|
||||
|
||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
||||
{brandingPreferencesFormEnabled ? (
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
context="Organisation"
|
||||
@@ -160,6 +168,8 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<BrandingUpsell />
|
||||
) : (
|
||||
<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">
|
||||
|
||||
@@ -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<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
||||
}, [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 (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
|
||||
|
||||
<EmailDomainsUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingEmailDomain) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
@@ -132,7 +146,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle}>
|
||||
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<section>
|
||||
<OrganisationEmailDomainsDataTable />
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<EmailDomainsUpsell />
|
||||
) : (
|
||||
<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">
|
||||
|
||||
@@ -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({
|
||||
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 (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
<SsoPortalUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
|
||||
@@ -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() {
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<BrandingUpsell />
|
||||
) : (
|
||||
<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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+14
-14
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user