diff --git a/web/messages/en/common.json b/web/messages/en/common.json index d17c436882..96bdd14546 100644 --- a/web/messages/en/common.json +++ b/web/messages/en/common.json @@ -10,10 +10,19 @@ "misc_secret": "Secret", "misc_active": "Active", "misc_disabled": "Disabled", + "misc_expired": "Expired", "license_business_required": "Available in Business plan.", "license_upgrade_business_tooltip": "This feature is part of a paid plan.\nUpgrade to Business to activate it.", "license_enterprise_required": "Available in Enterprise plan.", "license_upgrade_to_unlock": "Upgrade to unlock.", + "license_no_license": "No license", + "license_plan_usage_business": "Business plan usage", + "license_plan_usage_enterprise": "Enterprise plan usage", + "license_open_source_message": "You're using the open-source version with limited features. Try Business for free to unlock paid features and extended limits.", + "license_see_other_plans": "See other plans", + "license_approaching_limits": "You're approaching the limits of your current plan. To increase your limits, please upgrade to a higher-tier plan.", + "license_capacity_reached": "You've reached your plan's maximum capacity. Upgrade today to avoid interruptions and gain more flexibility.", + "contact_sales": "Contact sales", "controls_connect": "Connect", "controls_search": "Search", "controls_accept": "Accept", diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index d9cb2ccb94..82c14c4351 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -51,6 +51,41 @@ "settings_activity_log_streaming_table_stream_type_name": "Destination", "settings_msg_saved": "Settings saved", "settings_msg_save_failed": "Failed to save settings", + "settings_license_title": "License management", + "settings_license_subtitle": "Manage your Defguard license, view usage details, and track plan limits.", + "settings_license_current_plan": "Current plan", + "settings_license_no_plan": "No license", + "settings_license_key_title": "License key", + "settings_license_key_description": "Enter your license key to unlock additional Defguard features. Your license key is sent by email after purchase or registration on the Plans page.", + "settings_license_edit_button": "Edit license", + "settings_license_enter_button": "Enter license", + "settings_license_choose_plan_title": "Choose plan that matches your needs", + "settings_license_expand_plan_title": "Expand your possibilities with advanced plans", + "settings_license_compare_our_plans": "Compare our plans", + "settings_license_plan_business_title": "Business", + "settings_license_plan_business_badge": "Most popular", + "settings_license_plan_business_description": "External SSO & SIEM integration, LDAP/Active Directory, Firewall Management, REST API, Real-time clients configuration updates and more!", + "settings_license_plan_business_promotional_copy": "Test all business features of Defguard with up to 5 users and 1 location, allowing you to experience the platform's full capabilities before scaling.", + "settings_license_plan_enterprise_title": "Enterprise", + "settings_license_plan_enterprise_description": "Expand your security with: High Availability, Pre-logon/Always-on VPN, and upcoming support for Device Posture and Hardware based MFA.", + "settings_license_try_business_button": "Try Business for free now", + "settings_license_expired_banner": "Your license key has expired. Please renew your license to continue using Defguard {tier} features.", + "settings_license_expiring_soon_banner": "Your license will expire in {days} days. Please renew your license to avoid losing access to the features included in your current plan.", + "settings_license_expired_notice_title": "License expiration notice", + "settings_license_expired_notice_description_grace_period": "Defguard will continue to work for {duration}, after which it will downgrade to the Open Source version. Please renew your license as soon as possible to avoid losing access to the features included in your current plan.", + "settings_license_expired_notice_description": "Your license has expired. Please renew your license to continue using Defguard {tier} features.", + "settings_license_expired_notice_button": "Update your license now", + "settings_license_unknown": "Unknown", + "settings_license_type_title": "License type", + "settings_license_subscription_type": "Subscription", + "settings_license_offline_type": "Offline", + "settings_license_support_type_title": "Support type", + "settings_license_support_type_value": "Community support", + "settings_license_valid_until_title": "Valid until", + "settings_license_valid_until_with_time_left": "{date} ({duration} left)", + "settings_license_limits_title": "Current plan limits", + "settings_license_users_limit_label": "Added users", + "settings_license_locations_limit_label": "VPN locations", "settings_smtp_reset_confirm_title": "Reset SMTP Settings", "settings_smtp_reset_confirm_body": "Are you sure you want to reset SMTP settings? This action cannot be undone.", "settings_smtp_reset_success": "SMTP settings reset", diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx index 0ac2e4d8b1..d08548f85e 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx @@ -1,7 +1,7 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; -import { Fragment } from 'react/jsx-runtime'; -import { LicenseTier } from '../../../../../shared/api/types'; +import { m } from '../../../../../paraglide/messages'; +import type { LicenseInfo } from '../../../../../shared/api/types'; import { Controls } from '../../../../../shared/components/Controls/Controls'; import { DescriptionBlock } from '../../../../../shared/components/DescriptionBlock/DescriptionBlock'; import { SettingsCard } from '../../../../../shared/components/SettingsCard/SettingsCard'; @@ -9,13 +9,8 @@ import { SettingsHeader } from '../../../../../shared/components/SettingsHeader/ import { SettingsLayout } from '../../../../../shared/components/SettingsLayout/SettingsLayout'; import { AppText } from '../../../../../shared/defguard-ui/components/AppText/AppText'; import { Badge } from '../../../../../shared/defguard-ui/components/Badge/Badge'; -import { - type BadgeProps, - BadgeVariant, -} from '../../../../../shared/defguard-ui/components/Badge/types'; import { Button } from '../../../../../shared/defguard-ui/components/Button/Button'; import { Divider } from '../../../../../shared/defguard-ui/components/Divider/Divider'; -import { ExternalLink } from '../../../../../shared/defguard-ui/components/ExternalLink/ExternalLink'; import { SizedBox } from '../../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { TextStyle, @@ -29,69 +24,57 @@ import { getLicenseInfoQueryOptions, getSettingsQueryOptions, } from '../../../../../shared/query'; -import businessImage from './assets/business.png'; -import enterpriseImage from './assets/enterprise.png'; +import { getLicenseState, type LicenseState } from '../../../../../shared/utils/license'; +import { SettingsLicenseBusinessUpsellSection } from './components/SettingsLicenseBusinessUpsellSection/SettingsLicenseBusinessUpsellSection'; +import { SettingsLicenseExpiredNotice } from './components/SettingsLicenseExpiredNotice/SettingsLicenseExpiredNotice'; import { SettingsLicenseInfoSection } from './components/SettingsLicenseInfoSection/SettingsLicenseInfoSection'; +import { SettingsLicenseNoLicenseSection } from './components/SettingsLicenseNoLicenseSection/SettingsLicenseNoLicenseSection'; import { SettingsLicenseModal } from './modals/SettingsLicenseModal/SettingsLicenseModal'; -type LicenseItemData = { - imageSrc: string; - title: string; - description: string; - badges?: BadgeProps[]; -}; - -const licenses: Array = [ - { - title: 'Business', - imageSrc: businessImage, - description: `Advanced protection, shared access controls, and centralized billing. Ideal for small to medium teams.`, - badges: [{ text: 'Most popular', variant: BadgeVariant.Plan }], - }, - { - title: 'Enterprise', - imageSrc: enterpriseImage, - description: `Custom integrations, and dedicated support tailored to your organization’s security and scalability needs.`, - }, -]; - export const SettingsLicenseTab = () => { const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); const { data: settings } = useQuery(getSettingsQueryOptions); - const licenseTier = licenseInfo?.tier ?? null; + const licenseState = getLicenseState(licenseInfo); return ( {isPresent(settings) && ( - {isPresent(licenseInfo) && ( - - )} + {isPresent(licenseInfo) && + isPresent(licenseState) && + licenseState !== 'noLicense' && ( + + )} {!isPresent(licenseInfo) && (
- {`Current plan`} + {m.settings_license_current_plan()} - +
)} - -

{`Enter your license key to unlock additional Defguard features. Your license key is sent by email after purchase or registration on the Plans page.`}

+ +

{m.settings_license_key_description()}

+ + + +
+ ); +}; diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseExpiredNotice/SettingsLicenseExpiredNotice.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseExpiredNotice/SettingsLicenseExpiredNotice.tsx new file mode 100644 index 0000000000..b5588b97bc --- /dev/null +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseExpiredNotice/SettingsLicenseExpiredNotice.tsx @@ -0,0 +1,62 @@ +import dayjs from 'dayjs'; +import { m } from '../../../../../../../paraglide/messages'; +import type { LicenseInfo } from '../../../../../../../shared/api/types'; +import { SettingsCard } from '../../../../../../../shared/components/SettingsCard/SettingsCard'; +import { + externalLink, + licenseGracePeriodDays, +} from '../../../../../../../shared/constants'; +import { Button } from '../../../../../../../shared/defguard-ui/components/Button/Button'; +import expiredImage from '../../assets/expired.png'; + +type Props = { + licenseInfo: LicenseInfo; + state: 'gracePeriod' | 'expiredLicense'; +}; + +export const SettingsLicenseExpiredNotice = ({ licenseInfo, state }: Props) => { + const gracePeriodDaysLeft = getGracePeriodDaysLeft(licenseInfo.valid_until); + + const remainingDuration = m.settings_duration_days({ days: gracePeriodDaysLeft }); + + const description = + state === 'expiredLicense' + ? m.settings_license_expired_notice_description({ tier: licenseInfo.tier }) + : m.settings_license_expired_notice_description_grace_period({ + duration: remainingDuration, + }); + + return ( + +
+
+ +
+
+

{m.settings_license_expired_notice_title()}

+

{description}

+ +
+
+
+ ); +}; + +const getGracePeriodDaysLeft = (validUntil: string | null): number => { + const gracePeriodEndsAt = validUntil + ? dayjs.utc(validUntil).local().add(licenseGracePeriodDays, 'day') + : null; + + return gracePeriodEndsAt + ? Math.max(gracePeriodEndsAt.startOf('day').diff(dayjs().startOf('day'), 'day'), 0) + : 0; +}; diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/SettingsLicenseInfoSection.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/SettingsLicenseInfoSection.tsx index a6b3058fb4..65008bcd8c 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/SettingsLicenseInfoSection.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/SettingsLicenseInfoSection.tsx @@ -1,6 +1,7 @@ import { Fragment, type PropsWithChildren, useMemo } from 'react'; import './style.scss'; import dayjs from 'dayjs'; +import { m } from '../../../../../../../paraglide/messages'; import type { LicenseInfo, LicenseLimitsInfo, @@ -11,50 +12,98 @@ import { Icon, type IconKindValue, } from '../../../../../../../shared/defguard-ui/components/Icon'; +import { InfoBanner } from '../../../../../../../shared/defguard-ui/components/InfoBanner/InfoBanner'; import { ProgressionBar } from '../../../../../../../shared/defguard-ui/components/ProgressionBar/ProgressionBar'; import { SizedBox } from '../../../../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../../../../../shared/defguard-ui/types'; import { isPresent } from '../../../../../../../shared/defguard-ui/utils/isPresent'; +import type { LicenseState } from '../../../../../../../shared/utils/license'; type Props = { licenseInfo: LicenseInfo; + licenseState: LicenseState; }; -export const SettingsLicenseInfoSection = ({ licenseInfo: license }: Props) => { +export const SettingsLicenseInfoSection = ({ + licenseInfo: license, + licenseState, +}: Props) => { const licenseTier = license.tier; + const isGracePeriod = licenseState === 'gracePeriod'; + const isExpired = licenseState === 'expiredLicense'; + const isValid = licenseState === 'validBusiness' || licenseState === 'validEnterprise'; + const daysUntilExpiration = isPresent(license.valid_until) + ? dayjs + .utc(license.valid_until) + .local() + .startOf('day') + .diff(dayjs().startOf('day'), 'day') + : null; + const isOfflineExpiringSoon = + isValid && + !license.subscription && + daysUntilExpiration !== null && + daysUntilExpiration > 0 && + daysUntilExpiration <= 30; + return (
- + {isPresent(licenseTier) && ( <>

{licenseTier}

- {license.expired && } - {!license.expired && } + {isExpired && } + {isGracePeriod && } + {isValid && } )} {!isPresent(licenseTier) && (
- +
)}
- -

{license.subscription ? 'Subscription' : 'Offline'}

+ +

+ {license.subscription + ? m.settings_license_subscription_type() + : m.settings_license_offline_type()} +

- -

{`Placeholder`}

+ +

{m.settings_license_support_type_value()}

- {!license.expired && isPresent(license.valid_until) && ( - + {isPresent(license.valid_until) && ( + )}
+ {isExpired && ( + <> + + + + )} + {!isExpired && isOfflineExpiringSoon && ( + <> + + + + )} - {isPresent(license.limits) && ( + {!isExpired && isPresent(license.limits) && ( -

{`Current plan limits`}

+

{m.settings_license_limits_title()}

@@ -72,11 +121,16 @@ const ValidUntil = ({ validUntil }: ValidUntilProps) => { const untilDay = dayjs.utc(validUntil).local(); const nowDay = dayjs(); const diff = untilDay.diff(nowDay, 'days'); - let res = untilDay.format('DD/MM/YYYY'); + const formattedDate = untilDay.format('ll'); + if (diff > 0 && diff <= 28) { - res += ` (${untilDay.fromNow(true)})`; + return m.settings_license_valid_until_with_time_left({ + date: formattedDate, + duration: untilDay.fromNow(true), + }); } - return res; + + return formattedDate; }, [validUntil]); return

{display}

; @@ -90,13 +144,13 @@ const LimitsSection = ({ limits }: LimitSectionProps) => { return (
{ + return ( + +
+
{m.settings_license_choose_plan_title()}
+ + {m.settings_license_compare_our_plans()} + +
+ +
+
+
+ +
+
+
+

{m.settings_license_plan_business_title()}

+ +
+

+ {m.settings_license_plan_business_description()} +

+ +

+ {m.settings_license_plan_business_promotional_copy()} +

+ + +
+
+
+ +
+
+
+ +
+
+
+

{m.settings_license_plan_enterprise_title()}

+
+

+ {m.settings_license_plan_enterprise_description()} +

+ + +
+
+
+
+ ); +}; diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/style.scss b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/style.scss index 8b0703853e..67d1fee17a 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/style.scss +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/style.scss @@ -3,6 +3,7 @@ display: flex; flex-flow: row; align-items: center; + gap: var(--spacing-md); & > a { margin-left: auto; @@ -51,6 +52,49 @@ font: var(--t-body-sm-400); color: var(--fg-muted); } + + .promotional-copy { + font: var(--t-body-sm-600); + color: var(--fg-success); + } + + .actions { + display: flex; + flex-flow: row wrap; + gap: var(--spacing-md); + } + } + } + } +} + +#license-expired-notice { + .notice-track { + display: grid; + grid-template-columns: 68px 1fr; + gap: var(--spacing-xl); + align-items: start; + + .image-track { + img { + width: 100%; + } + } + + .content-track { + display: flex; + flex-flow: column; + align-items: flex-start; + gap: var(--spacing-md); + + .title { + color: var(--fg-critical); + font: var(--t-title-h5); + } + + .description { + color: var(--fg-default); + font: var(--t-body-primary-400); } } } diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 61124dd54a..7e4e5bda67 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -365,7 +365,6 @@ export interface LicenseCheckResponse { } export const LicenseTier = { - Starter: 'Starter', Business: 'Business', Enterprise: 'Enterprise', } as const; diff --git a/web/src/shared/components/PageTopBar/components/TopBarLicense/TopBarLicenseFloating.tsx b/web/src/shared/components/PageTopBar/components/TopBarLicense/TopBarLicenseFloating.tsx index c106ac4c6c..21cb57e9e9 100644 --- a/web/src/shared/components/PageTopBar/components/TopBarLicense/TopBarLicenseFloating.tsx +++ b/web/src/shared/components/PageTopBar/components/TopBarLicense/TopBarLicenseFloating.tsx @@ -1,4 +1,5 @@ import { type HTMLProps, useMemo } from 'react'; +import { m } from '../../../../../paraglide/messages'; import type { LicenseInfo } from '../../../../api/types'; import { externalLink } from '../../../../constants'; import { Button } from '../../../../defguard-ui/components/Button/Button'; @@ -29,12 +30,12 @@ export const TopBarLicenseFloating = ({ license, ...props }: Props) => { }, [license]); const title = useMemo(() => { - if (license === null) return 'No License'; + if (license === null) return m.license_no_license(); switch (license.tier) { case 'Business': - return 'Business Plan usage'; + return m.license_plan_usage_business(); case 'Enterprise': - return 'Enterprise Plan usage'; + return m.license_plan_usage_enterprise(); } }, [license]); @@ -49,34 +50,36 @@ export const TopBarLicenseFloating = ({ license, ...props }: Props) => { icon={IconKind.Users} value={license.limits.users.current} maxValue={license.limits.users.limit} - label="Added users" + label={m.settings_license_users_limit_label()} />
)} {!license.limits_exceeded && warning && ( -

{`You're approaching the limits of your current plan. To increase your limits, please upgrade to a higher-tier plan.`}

+

{m.license_approaching_limits()}

)} {license.limits_exceeded && ( -

{`You've reached your plan's maximum capacity. Upgrade today to avoid interruptions and gain more flexibility.`}

+

{m.license_capacity_reached()}

)} )} {!isPresent(license) && ( -

- {`You're using the open-source version with limited features.\n\nTo unlock more flexibility, upgrade your license to “Starter” for free.`} -

+

{m.license_open_source_message()}

)}
diff --git a/web/src/shared/components/PageTopBar/components/TopBarLicenseExpiration/TopBarLicenseExpiration.tsx b/web/src/shared/components/PageTopBar/components/TopBarLicenseExpiration/TopBarLicenseExpiration.tsx index 158ec90e69..c3c6c0c1f3 100644 --- a/web/src/shared/components/PageTopBar/components/TopBarLicenseExpiration/TopBarLicenseExpiration.tsx +++ b/web/src/shared/components/PageTopBar/components/TopBarLicenseExpiration/TopBarLicenseExpiration.tsx @@ -7,6 +7,7 @@ import { Icon, IconKind } from '../../../../defguard-ui/components/Icon'; import { ThemeVariable } from '../../../../defguard-ui/types'; import { isPresent } from '../../../../defguard-ui/utils/isPresent'; import { getLicenseInfoQueryOptions } from '../../../../query'; +import { getLicenseState } from '../../../../utils/license'; import { TopBarElementSkeleton } from '../../TopBarElementSkeleton'; export const TopBarLicenseExpiration = () => { @@ -21,16 +22,9 @@ type MessageVariant = 'warning' | 'expired' | 'critical' | 'safe'; const Content = () => { const { data: license } = useSuspenseQuery(getLicenseInfoQueryOptions); + const licenseState = getLicenseState(license); - const isGracePeriod = useMemo(() => { - if (!license) return false; - const current = dayjs(); - const expires = dayjs(license.valid_until); - if (current.isAfter(expires) && !license.expired) { - return true; - } - return false; - }, [license]); + const isGracePeriod = licenseState === 'gracePeriod'; const expiresDisplay = useMemo(() => { if (license === null || license.valid_until === null) return ''; @@ -48,20 +42,18 @@ const Content = () => { const variant = useMemo((): MessageVariant => { if (!isPresent(license) || license.valid_until === null || daysToEnd === null) return 'safe'; + if (isGracePeriod) { + return 'critical'; + } + if (licenseState === 'expiredLicense') return 'expired'; if (license.subscription) { - if (isGracePeriod) { - return 'critical'; - } - if (!license.expired) { - return 'safe'; - } + return 'safe'; } - if (license.expired) return 'expired'; if (daysToEnd > 14) return 'safe'; if (daysToEnd <= 14 && daysToEnd > 7) return 'warning'; if (daysToEnd <= 7) return 'critical'; return 'expired'; - }, [daysToEnd, license, isGracePeriod]); + }, [daysToEnd, isGracePeriod, license, licenseState]); if (!isPresent(license) || daysToEnd === null || variant === 'safe') return null; return ( diff --git a/web/src/shared/constants.ts b/web/src/shared/constants.ts index 404c0f9194..f6e8afa748 100644 --- a/web/src/shared/constants.ts +++ b/web/src/shared/constants.ts @@ -5,6 +5,7 @@ export const externalLink = { docs: 'https://docs.defguard.net', pricing: 'https://defguard.net/pricing', download: 'https://defguard.net/download', + sales: 'mailto:sales@defguard.net', }, client: { desktop: { @@ -39,6 +40,8 @@ export const googleProviderBaseUrl = 'https://accounts.google.com'; export const jumpcloudProviderBaseUrl = 'https://oauth.id.jumpcloud.com'; +export const licenseGracePeriodDays = 14; + export const edgeDefaultGrpcPort = 50051; export const gatewayDefaultGrpcPort = 50066; diff --git a/web/src/shared/utils/license.ts b/web/src/shared/utils/license.ts index 7c4aac3c0d..525bdd146c 100644 --- a/web/src/shared/utils/license.ts +++ b/web/src/shared/utils/license.ts @@ -1,13 +1,51 @@ +import dayjs from 'dayjs'; import type { LicenseInfo } from '../api/types'; import { openModal } from '../hooks/modalControls/modalsSubjects'; import { ModalName } from '../hooks/modalControls/modalTypes'; +export type LicenseState = + | 'noLicense' + | 'gracePeriod' + | 'expiredLicense' + | 'validBusiness' + | 'validEnterprise'; + interface LicenseCheckResult { result: boolean; error: 'expired' | 'tier' | null; tierCheck: 'Business' | 'Enterprise'; } +export const getLicenseState = ( + licenseInfo: LicenseInfo | null | undefined, +): LicenseState | null => { + if (licenseInfo === undefined) { + return null; + } + + if (licenseInfo === null) { + return 'noLicense'; + } + + if (licenseInfo.expired) { + return 'expiredLicense'; + } + + if ( + licenseInfo.subscription && + licenseInfo.valid_until !== null && + dayjs().isAfter(dayjs.utc(licenseInfo.valid_until).local()) + ) { + return 'gracePeriod'; + } + + if (licenseInfo.tier === 'Enterprise') { + return 'validEnterprise'; + } + + return 'validBusiness'; +}; + export const licenseActionCheck = ( checkResult: LicenseCheckResult, successCallback: () => void,