diff --git a/crates/defguard_core/src/enterprise/handlers/mod.rs b/crates/defguard_core/src/enterprise/handlers/mod.rs index 8083e7778..084b1a117 100644 --- a/crates/defguard_core/src/enterprise/handlers/mod.rs +++ b/crates/defguard_core/src/enterprise/handlers/mod.rs @@ -1,6 +1,6 @@ use crate::{ auth::{AdminRole, SessionInfo}, - enterprise::get_counts, + enterprise::{get_counts, is_enterprise_free}, handlers::{ApiResponse, ApiResult}, }; @@ -16,6 +16,8 @@ use axum::{ http::{StatusCode, request::Parts}, }; +use serde::Serialize; + use super::{ db::models::enterprise_settings::EnterpriseSettings, is_business_license_active, license::get_cached_license, @@ -29,6 +31,21 @@ pub struct LicenseInfo { /// Used to check if user is allowed to manage his devices. pub struct CanManageDevices; +#[derive(Serialize)] +struct LimitInfo { + current: u32, + limit: u32, +} + +#[derive(Serialize)] +struct LicenseLimitsInfo { + users: LimitInfo, + locations: LimitInfo, + user_devices: Option, + network_devices: Option, + devices: Option, +} + impl FromRequestParts for LicenseInfo where S: Send + Sync, @@ -50,18 +67,50 @@ where /// Gets full information about enterprise status. pub async fn check_enterprise_info(_admin: AdminRole, _session: SessionInfo) -> ApiResult { let license = get_cached_license(); - let license_info = license.as_ref().map(|license| { - let counts = get_counts(); - serde_json::json!( - { - "valid_until": license.valid_until, - "subscription": license.subscription, - "expired": license.is_max_overdue(), - "limits_exceeded": counts.is_over_license_limits(license), - "tier": license.tier - } - ) - }); + let license_info = license + .as_ref() + .map(|license: &crate::enterprise::license::License| { + let counts = get_counts(); + let limits_info = license.limits.map(|limits| LicenseLimitsInfo { + locations: LimitInfo { + current: counts.location(), + limit: limits.locations, + }, + users: LimitInfo { + current: counts.user(), + limit: limits.users, + }, + devices: limits.network_devices.map_or( + Some(LimitInfo { + current: counts.user_device() + counts.network_device(), + limit: limits.devices, + }), + |_| None, + ), + user_devices: limits.network_devices.map(|_| LimitInfo { + current: counts.user_device(), + limit: limits.devices, + }), + network_devices: limits + .network_devices + .map(|network_devices_limit| LimitInfo { + current: counts.network_device(), + limit: network_devices_limit, + }), + }); + + serde_json::json!( + { + "free": is_enterprise_free(), + "valid_until": license.valid_until, + "subscription": license.subscription, + "expired": license.is_max_overdue(), + "limits_exceeded": counts.is_over_license_limits(license), + "tier": license.tier, + "limits": limits_info, + } + ) + }); Ok(ApiResponse { json: serde_json::json!( { diff --git a/crates/defguard_core/src/enterprise/limits.rs b/crates/defguard_core/src/enterprise/limits.rs index ff78223a6..7a7c17e54 100644 --- a/crates/defguard_core/src/enterprise/limits.rs +++ b/crates/defguard_core/src/enterprise/limits.rs @@ -114,6 +114,22 @@ impl Counts { } } + pub(crate) fn user(&self) -> u32 { + self.user + } + + pub(crate) fn user_device(&self) -> u32 { + self.user_device + } + + pub(crate) fn network_device(&self) -> u32 { + self.network_device + } + + pub(crate) fn location(&self) -> u32 { + self.location + } + // New licenses have a network device limit field, this function handles backwards compatibility // If no such field is present = old behavior (user devices + network devices <= devices limit) // If field is present, check user devices and network devices separately diff --git a/web/package.json b/web/package.json index 61cf8b9e1..9b70c1c9f 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,7 @@ "dependencies": { "@axa-ch/react-polymorphic-types": "^1.4.1", "@floating-ui/react": "^0.27.16", - "@inlang/paraglide-js": "^2.7.1", + "@inlang/paraglide-js": "^2.7.2", "@react-hook/resize-observer": "^2.0.2", "@shortercode/webzip": "1.1.1-0", "@stablelib/base64": "^2.0.1", @@ -24,10 +24,10 @@ "@tanstack/react-form": "^1.27.7", "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", - "@tanstack/react-router": "^1.145.7", - "@tanstack/react-router-devtools": "^1.145.7", + "@tanstack/react-router": "^1.145.11", + "@tanstack/react-router-devtools": "^1.145.11", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.17", + "@tanstack/react-virtual": "^3.13.18", "@uidotdev/usehooks": "^2.4.1", "axios": "^1.13.2", "byte-size": "^9.0.1", @@ -37,7 +37,7 @@ "humanize-duration": "^3.33.2", "ipaddr.js": "^2.3.0", "lodash-es": "^4.17.22", - "motion": "^12.24.10", + "motion": "^12.24.11", "qrcode.react": "^4.2.0", "qs": "^6.14.1", "react": "^19.2.3", @@ -56,7 +56,7 @@ "@biomejs/biome": "2.3.11", "@inlang/paraglide-js": "2.7.2", "@tanstack/devtools-vite": "^0.4.1", - "@tanstack/router-plugin": "^1.145.10", + "@tanstack/router-plugin": "^1.145.11", "@types/byte-size": "^8.1.2", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 4f1ac34be..a507d2ca1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@inlang/paraglide-js': - specifier: ^2.7.1 - version: 2.7.1 + specifier: ^2.7.2 + version: 2.7.2 '@react-hook/resize-observer': specifier: ^2.0.2 version: 2.0.2(react@19.2.3) @@ -42,17 +42,17 @@ importers: specifier: ^5.91.2 version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3) '@tanstack/react-router': - specifier: ^1.145.7 - version: 1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.145.11 + version: 1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-devtools': - specifier: ^1.145.7 - version: 1.145.7(@tanstack/react-router@1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.145.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.9) + specifier: ^1.145.11 + version: 1.145.11(@tanstack/react-router@1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.145.11)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.9) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-virtual': - specifier: ^3.13.17 - version: 3.13.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^3.13.18 + version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -81,8 +81,8 @@ importers: specifier: ^4.17.22 version: 4.17.22 motion: - specifier: ^12.24.10 - version: 12.24.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^12.24.11 + version: 12.24.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.3) @@ -130,8 +130,8 @@ importers: specifier: ^0.4.1 version: 0.4.1(vite@7.3.1(@types/node@25.0.3)(sass@1.97.2)(tsx@4.21.0)) '@tanstack/router-plugin': - specifier: ^1.145.10 - version: 1.145.10(@tanstack/react-router@1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(sass@1.97.2)(tsx@4.21.0)) + specifier: ^1.145.11 + version: 1.145.11(@tanstack/react-router@1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(sass@1.97.2)(tsx@4.21.0)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -346,8 +346,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.22': - resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + '@csstools/css-syntax-patches-for-csstree@1.0.23': + resolution: {integrity: sha512-YEmgyklR6l/oKUltidNVYdjSmLSW88vMsKx0pmiS3r71s8ZZRpd8A0Yf0U+6p/RzElmMnPBv27hNWjDQMSZRtQ==} engines: {node: '>=18'} '@csstools/css-tokenizer@3.0.4': @@ -687,8 +687,8 @@ packages: cpu: [x64] os: [win32] - '@inlang/paraglide-js@2.7.1': - resolution: {integrity: sha512-wCpnS9iRTRYMilvWBjB0ndf8+moon+AXz23Uh4wbpQjWhRJyvCytkGFzm7jeqAGggK4v3oeuyjva91TDMS+qhw==} + '@inlang/paraglide-js@2.7.2': + resolution: {integrity: sha512-tUkzcK7yCuJsQCeSHLlCaISFFX65efYeYdFBXtTXyorl1PClH2/BxFg4MqCsemXiX7KZWgbvIfjnsDiVTrj7bQ==} hasBin: true '@inlang/recommend-sherlock@0.2.1': @@ -1199,20 +1199,20 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.145.7': - resolution: {integrity: sha512-crzHSQ/rcGX7RfuYsmm1XG5quurNMDTIApU7jfwDx5J9HnUxCOSJrbFX0L3w0o0VRCw5xhrL2EdCnW78Ic86hg==} + '@tanstack/react-router-devtools@1.145.11': + resolution: {integrity: sha512-GngvMMa9QdoLKDegWk0ruxthzz594Zeqgrs7APBN2StuqneBk4lENTZA5nMj4Q3ehuGrZzbCLUCdpTpUQEKXFw==} engines: {node: '>=12'} peerDependencies: - '@tanstack/react-router': ^1.145.7 - '@tanstack/router-core': ^1.145.7 + '@tanstack/react-router': ^1.145.11 + '@tanstack/router-core': ^1.145.11 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.145.7': - resolution: {integrity: sha512-0O+a4TjJSPXd2BsvDPwDPBKRQKYqNIBg5TAg9NzCteqJ0NXRxwohyqCksHqCEEtJe/uItwqmHoqkK4q5MDhEsA==} + '@tanstack/react-router@1.145.11': + resolution: {integrity: sha512-dkXX4dOmPOkR8nG4JhwLGKqKR0B4434/bInX3CNjKGxxf4CRKLeYrd0gYkIzukehx7Ltcd6kUEYGUEWqk7p0xQ==} engines: {node: '>=12'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1231,37 +1231,37 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.17': - resolution: {integrity: sha512-gtjQr4CIb86rq03AL4WJnlTaaTU5UU4Xt8tbG1HU3OWVsO4z5OrRKTRDKoWRbkLEPpbPIjPgCoxmV70jTJtWZQ==} + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.145.7': - resolution: {integrity: sha512-v6jx6JqVUBM0/FcBq1tX22xiPq8Ufc0PDEP582/4deYoq2/RYd+bZstANp3mGSsqdxE/luhoLYuuSQiwi/j1wA==} + '@tanstack/router-core@1.145.11': + resolution: {integrity: sha512-FmctIfu+Sw2Iu1+OKNhgPYMgWQvzsCLAEDXRIrn1xMD6aeHYWpIO/twhdriA7LY3ad8ylX/ABSFZr/DoeMHCYw==} engines: {node: '>=12'} - '@tanstack/router-devtools-core@1.145.7': - resolution: {integrity: sha512-oKeq/6QvN49THCh++FJyPv1X65i20qGS4aJHQTNsl4cu1piW1zWUhab2L3DZVr3G8C40FW3xb6hVw92N/fzZbQ==} + '@tanstack/router-devtools-core@1.145.11': + resolution: {integrity: sha512-Ql/mmUE0EzT8h3A7AIN84iVZjWNSchjtH9FS/cfarlLFcgcL06eU8eeDxRyq7UWLBAfX/v2Hcv1FPtjaQv4nWQ==} engines: {node: '>=12'} peerDependencies: - '@tanstack/router-core': ^1.145.7 + '@tanstack/router-core': ^1.145.11 csstype: ^3.0.10 solid-js: '>=1.9.5' peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.145.7': - resolution: {integrity: sha512-xg71c1WTku0ro0rgpJWh3Dt+ognV9qWe2KJHAPzrqfOYdUYu9sGq7Ri4jo8Rk0luXWZrWsrFdBP+9Jx6JH6zWA==} + '@tanstack/router-generator@1.145.11': + resolution: {integrity: sha512-WmxtJZjugSCZCyAGygShlywBnoOlPyu57HYIspUNyffPPDhpuIkf+GP2U3TTGsxkXpmFZNTeki3bobo+sP34Cg==} engines: {node: '>=12'} - '@tanstack/router-plugin@1.145.10': - resolution: {integrity: sha512-2001Qu/aUdEXVjyKa21/8HXXmgyDwTEtvgNoWaB9H6KmpqUUnlNuh+hlfa1tjGSnlFevkjbLb3NfveSy/Bvynw==} + '@tanstack/router-plugin@1.145.11': + resolution: {integrity: sha512-DhpMeiPGGCkRKG/Ct2bJzn56byvSE8n6sI+UpmMYB5GnlBKlj1pjt6SNQM7xapc1sucaEX4mA8i31JceYpHMxw==} engines: {node: '>=12'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.145.7 + '@tanstack/react-router': ^1.145.11 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1291,8 +1291,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.17': - resolution: {integrity: sha512-m5mRfGNcL5GUzluWNom0Rmg8P8Dg3h6PnJtJBmJcBiJvkV+vufmUfLnVzKSPGQtmvzMW/ZuUdvL+SyjIUvHV3A==} + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} '@tanstack/virtual-file-routes@1.145.4': resolution: {integrity: sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ==} @@ -1462,8 +1462,8 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} - baseline-browser-mapping@2.9.11: - resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + baseline-browser-mapping@2.9.12: + resolution: {integrity: sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==} hasBin: true binary-extensions@2.3.0: @@ -1503,8 +1503,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001762: - resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + caniuse-lite@1.0.30001763: + resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1819,8 +1819,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.24.10: - resolution: {integrity: sha512-8yoyMkCn2RmV9UB9mfmMuzKyenQe909hRQRl0yGBhbZJjZZ9bSU87NIGAruqCXCuTNCA0qHw2LWLrcXLL9GF6A==} + framer-motion@12.24.11: + resolution: {integrity: sha512-cNTxTGvFKcY/eQcuGMpG2rb7DOxEFW+A8h5MFhwPqQrn4Jyz9hFhJAlHpngxZIkS0Kk+rZiYmuxNMgO5dGxeYQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2216,14 +2216,14 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - motion-dom@12.24.10: - resolution: {integrity: sha512-H3HStYaJ6wANoZVNT0ZmYZHGvrpvi9pKJRzsgNEHkdITR4Qd9FFu2e9sH4e2Phr4tKCmyyloex6SOSmv0Tlq+g==} + motion-dom@12.24.11: + resolution: {integrity: sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==} motion-utils@12.24.10: resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==} - motion@12.24.10: - resolution: {integrity: sha512-P3tEZfL9N9Feb2sXVZaF085DHNyd4SO/X76Fo37pFv6DDAeL3vU4vYpTZBWiwW66qnF11VmFCM9DxnpAG825yg==} + motion@12.24.11: + resolution: {integrity: sha512-nfon8E7NDNh42QTJHwB9gjnfdGLFLf0eCu0izAJ7qmZ1NFODuVp+n20kfe5r5PEQlJyONjI0znSKm7QN8szQAQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3067,7 +3067,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + '@csstools/css-syntax-patches-for-csstree@1.0.23': {} '@csstools/css-tokenizer@3.0.4': {} @@ -3286,7 +3286,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inlang/paraglide-js@2.7.1': + '@inlang/paraglide-js@2.7.2': dependencies: '@inlang/recommend-sherlock': 0.2.1 '@inlang/sdk': 2.4.10 @@ -3753,23 +3753,23 @@ snapshots: '@tanstack/query-core': 5.90.16 react: 19.2.3 - '@tanstack/react-router-devtools@1.145.7(@tanstack/react-router@1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.145.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.9)': + '@tanstack/react-router-devtools@1.145.11(@tanstack/react-router@1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.145.11)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.9)': dependencies: - '@tanstack/react-router': 1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-devtools-core': 1.145.7(@tanstack/router-core@1.145.7)(csstype@3.2.3)(solid-js@1.9.9) + '@tanstack/react-router': 1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-devtools-core': 1.145.11(@tanstack/router-core@1.145.11)(csstype@3.2.3)(solid-js@1.9.9) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@tanstack/router-core': 1.145.7 + '@tanstack/router-core': 1.145.11 transitivePeerDependencies: - csstype - solid-js - '@tanstack/react-router@1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router@1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/history': 1.145.7 '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.145.7 + '@tanstack/router-core': 1.145.11 isbot: 5.1.32 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -3789,13 +3789,13 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/react-virtual@3.13.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/virtual-core': 3.13.17 + '@tanstack/virtual-core': 3.13.18 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/router-core@1.145.7': + '@tanstack/router-core@1.145.11': dependencies: '@tanstack/history': 1.145.7 '@tanstack/store': 0.8.0 @@ -3805,9 +3805,9 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.145.7(@tanstack/router-core@1.145.7)(csstype@3.2.3)(solid-js@1.9.9)': + '@tanstack/router-devtools-core@1.145.11(@tanstack/router-core@1.145.11)(csstype@3.2.3)(solid-js@1.9.9)': dependencies: - '@tanstack/router-core': 1.145.7 + '@tanstack/router-core': 1.145.11 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.9 @@ -3815,9 +3815,9 @@ snapshots: optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.145.7': + '@tanstack/router-generator@1.145.11': dependencies: - '@tanstack/router-core': 1.145.7 + '@tanstack/router-core': 1.145.11 '@tanstack/router-utils': 1.143.11 '@tanstack/virtual-file-routes': 1.145.4 prettier: 3.7.4 @@ -3828,7 +3828,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.145.10(@tanstack/react-router@1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(sass@1.97.2)(tsx@4.21.0))': + '@tanstack/router-plugin@1.145.11(@tanstack/react-router@1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(sass@1.97.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -3836,8 +3836,8 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/router-core': 1.145.7 - '@tanstack/router-generator': 1.145.7 + '@tanstack/router-core': 1.145.11 + '@tanstack/router-generator': 1.145.11 '@tanstack/router-utils': 1.143.11 '@tanstack/virtual-file-routes': 1.145.4 babel-dead-code-elimination: 1.0.11 @@ -3845,7 +3845,7 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.145.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-router': 1.145.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) vite: 7.3.1(@types/node@25.0.3)(sass@1.97.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -3868,7 +3868,7 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.17': {} + '@tanstack/virtual-core@3.13.18': {} '@tanstack/virtual-file-routes@1.145.4': {} @@ -4002,7 +4002,7 @@ snapshots: autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001762 + caniuse-lite: 1.0.30001763 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -4029,7 +4029,7 @@ snapshots: balanced-match@2.0.0: {} - baseline-browser-mapping@2.9.11: {} + baseline-browser-mapping@2.9.12: {} binary-extensions@2.3.0: {} @@ -4039,8 +4039,8 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001762 + baseline-browser-mapping: 2.9.12 + caniuse-lite: 1.0.30001763 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -4067,7 +4067,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001762: {} + caniuse-lite@1.0.30001763: {} ccount@2.0.1: {} @@ -4346,9 +4346,9 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.24.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.24.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - motion-dom: 12.24.10 + motion-dom: 12.24.11 motion-utils: 12.24.10 tslib: 2.8.1 optionalDependencies: @@ -4866,15 +4866,15 @@ snapshots: dependencies: mime-db: 1.52.0 - motion-dom@12.24.10: + motion-dom@12.24.11: dependencies: motion-utils: 12.24.10 motion-utils@12.24.10: {} - motion@12.24.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + motion@12.24.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.24.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.24.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: react: 19.2.3 @@ -5313,7 +5313,7 @@ snapshots: stylelint@16.26.1(typescript@5.9.3): dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-syntax-patches-for-csstree': 1.0.22 + '@csstools/css-syntax-patches-for-csstree': 1.0.23 '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) diff --git a/web/src/pages/AppLoaderPage/AppLoaderPage.tsx b/web/src/pages/AppLoaderPage/AppLoaderPage.tsx index bf8b3e049..3dc0cb40c 100644 --- a/web/src/pages/AppLoaderPage/AppLoaderPage.tsx +++ b/web/src/pages/AppLoaderPage/AppLoaderPage.tsx @@ -1,9 +1,12 @@ +import './style.scss'; +import { LoginPageLogo } from '../../shared/components/LoginPage/LoginPageLogo'; import { LoaderSpinner } from '../../shared/defguard-ui/components/LoaderSpinner/LoaderSpinner'; export const AppLoaderPage = () => { return (
- + +
); }; diff --git a/web/src/pages/AppLoaderPage/style.scss b/web/src/pages/AppLoaderPage/style.scss new file mode 100644 index 000000000..8ee504d56 --- /dev/null +++ b/web/src/pages/AppLoaderPage/style.scss @@ -0,0 +1,15 @@ +#app-loader-page { + display: flex; + flex-flow: column; + row-gap: var(--spacing-4xl); + height: 100dvh; + width: 100%; + align-items: center; + justify-content: center; + background-color: var(--bg-default); + + & > svg { + width: calc(129 * 2px); + height: calc(32 * 2px); + } +} diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx index 0ef8e67a8..b71ad9ce6 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/SettingsLicenseTab.tsx @@ -1,6 +1,7 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; import { Fragment } from 'react/jsx-runtime'; +import { LicenseTier } 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'; @@ -31,7 +32,6 @@ import { } from '../../../../../shared/query'; import businessImage from './assets/business.png'; import enterpriseImage from './assets/enterprise.png'; -import starterImage from './assets/starter.png'; import { SettingsLicenseInfoSection } from './components/SettingsLicenseInfoSection/SettingsLicenseInfoSection'; import { LicenseModal } from './modals/LicenseModal/LicenseModal'; @@ -43,12 +43,6 @@ type LicenseItemData = { }; const licenses: Array = [ - { - title: 'Starter', - imageSrc: starterImage, - description: `Advanced protection, shared access controls, and centralized billing. Ideal for small to medium teams.`, - badges: [{ text: 'Free', variant: BadgeVariant.Success }], - }, { title: 'Business', imageSrc: businessImage, @@ -67,6 +61,8 @@ export const SettingsLicenseTab = () => { const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); const { data: settings } = useQuery(getSettingsQueryOptions); + const licenseTier = licenseInfo?.tier ?? null; + return ( { )} - - -
-
{`Expand your possibilities with advanced plans`}
- - {`Select your plan`} - -
- - {licenses.map((data, index) => { - const isLast = index !== licenses.length - 1; - return ( - - - {isLast && } - - ); - })} -
- {/* modals */} + {isPresent(licenseTier) && !(licenseTier === LicenseTier.Enterprise) && ( + + + +
+
{`Expand your possibilities with advanced plans`}
+ + {`Select your plan`} + +
+ +
+ +
+
+
+ )}
); 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 a54a2031a..262a7be8c 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,4 +1,4 @@ -import { type PropsWithChildren, useMemo } from 'react'; +import { Fragment, type PropsWithChildren, useMemo } from 'react'; import './style.scss'; import dayjs from 'dayjs'; import type { LicenseInfo } from '../../../../../../../shared/api/types'; @@ -26,7 +26,8 @@ export const SettingsLicenseInfoSection = ({ licenseInfo: license }: Props) => { {isPresent(licenseTier) && ( <>

{licenseTier}

- + {license.expired && } + {!license.expired && } )} {!isPresent(licenseTier) && ( @@ -36,21 +37,25 @@ export const SettingsLicenseInfoSection = ({ licenseInfo: license }: Props) => { )} -

{`Offline`}

+

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

-

{`Community support`}

+

{`Placeholder`}

- {!license.expired && ( + {!license.expired && isPresent(license.valid_until) && ( )} -

{`Current plan limits`}

- - + {isPresent(license.limits) && ( + +

{`Current plan limits`}

+ + +
+ )} ); }; @@ -64,7 +69,11 @@ const ValidUntil = ({ validUntil }: ValidUntilProps) => { const untilDay = dayjs.utc(validUntil).local(); const nowDay = dayjs(); const diff = untilDay.diff(nowDay, 'days'); - return `${untilDay.format('DD/MM/YYYY')} (${diff} ${diff !== 1 ? 'days' : 'day'} left)`; + let res = untilDay.format('DD/MM/YYYY'); + if (diff > 0) { + res += ` (${diff} ${diff !== 1 ? 'days' : 'day'} left)`; + } + return res; }, [validUntil]); return

{display}

; @@ -74,16 +83,45 @@ type LimitSectionProps = { license: LicenseInfo; }; -const LimitsSection = (_props: LimitSectionProps) => { +const LimitsSection = ({ license: { limits } }: LimitSectionProps) => { return (
- + + {isPresent(limits.devices) && ( + + )} + {isPresent(limits.user_devices) && ( + + )} + {isPresent(limits.network_devices) && ( + + )}
); }; diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/style.scss b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/style.scss index 75f35381e..84f340273 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/style.scss +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/components/SettingsLicenseInfoSection/style.scss @@ -47,6 +47,12 @@ } .license-limit-progress { + .progression-bar.complete { + .bar { + background-color: var(--bg-critical); + } + } + & > .info { display: flex; flex-flow: row nowrap; diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/LicenseModal/LicenseModal.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/LicenseModal/LicenseModal.tsx index 3de59e4cd..5db61bd94 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/LicenseModal/LicenseModal.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/LicenseModal/LicenseModal.tsx @@ -60,7 +60,7 @@ export const LicenseModal = () => { }; const formSchema = z.object({ - license: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), + license: z.string(m.form_error_invalid()).trim().min(1, m.form_error_required()), }); type FormFields = z.infer; @@ -79,7 +79,7 @@ const ModalContent = ({ license: initialLicense }: ModalData) => { closeModal(modalNameValue); }, meta: { - invalidate: ['settings'], + invalidate: [['settings'], ['enterprise_info']], }, }); @@ -92,7 +92,7 @@ const ModalContent = ({ license: initialLicense }: ModalData) => { }, onSubmit: async ({ value, formApi }) => { await patchSettings({ - license: value.license, + license: value.license.replaceAll('\n', '').trim(), }).catch((e: AxiosError) => { if (e.status && e.status >= 400 && e.status < 500) { formApi.setErrorMap({ diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 30855e9db..42ee9caff 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -12,7 +12,7 @@ interface RouterContext { export const Route = createRootRouteWithContext()({ component: RootComponent, beforeLoad: async ({ location }) => { - // only auto check for auth if route is not in /auth flow + // only auto check for auth state if route is not in /auth flow if (location.pathname.startsWith('/auth')) { return; } @@ -34,7 +34,7 @@ export const Route = createRootRouteWithContext()({ } }, pendingComponent: AppLoaderPage, - pendingMs: 0, + pendingMs: 100, }); function RootComponent() { diff --git a/web/src/routes/_authorized.tsx b/web/src/routes/_authorized.tsx index 323a605ba..2f876ce15 100644 --- a/web/src/routes/_authorized.tsx +++ b/web/src/routes/_authorized.tsx @@ -3,6 +3,7 @@ import { DisplayListModal } from '../shared/components/DisplayListModal/DisplayL import { SelectionModal } from '../shared/components/modals/SelectionModal/SelectionModal'; import { useAuth } from '../shared/hooks/useAuth'; import { AppConfigProvider } from '../shared/providers/AppConfigProvider'; +import { AppUserProvider } from '../shared/providers/AppUserProvider'; export const Route = createFileRoute('/_authorized')({ component: RouteComponent, @@ -16,10 +17,12 @@ export const Route = createFileRoute('/_authorized')({ function RouteComponent() { return ( - - - - - + + + + + + + ); } diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 015fc264b..029e7f138 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -183,7 +183,7 @@ export interface ApiError { message?: string; } -export interface LicenseLimits { +export interface AppInfoExceededLimits { user: boolean; device: boolean; wireguard_network: boolean; @@ -191,18 +191,40 @@ export interface LicenseLimits { export interface LicenseAppInfo { enterprise: boolean; - limits_exceeded: LicenseLimits; + limits_exceeded: AppInfoExceededLimits; any_limit_exceeded: boolean; is_enterprise_free: boolean; tier?: string | null; } +export interface LimitInfo { + current: number; + limit: number; +} + +export interface LicenseLimitsInfo { + locations: LimitInfo; + users: LimitInfo; + user_devices: LimitInfo | null; + network_devices: LimitInfo | null; + devices: LimitInfo | null; +} + +export const LicenseTier = { + Business: 'Business', + Enterprise: 'Enterprise', +} as const; + +export type LicenseTierValue = (typeof LicenseTier)[keyof typeof LicenseTier]; + export interface LicenseInfo { + free: boolean; expired: boolean; limits_exceeded: boolean; subscription: boolean; - valid_until: string; - tier: string; + valid_until: string | null; + tier: LicenseTierValue; + limits: LicenseLimitsInfo; } export interface LicenseInfoResponse { diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index c328669db..830a59200 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit c328669db363dd91bb31a58b2bcb7990dded13f3 +Subproject commit 830a5920035d11cb714acb1e07183bdf3d830e4a diff --git a/web/src/shared/providers/AppUserProvider.tsx b/web/src/shared/providers/AppUserProvider.tsx new file mode 100644 index 000000000..c0036acc6 --- /dev/null +++ b/web/src/shared/providers/AppUserProvider.tsx @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { Fragment, type PropsWithChildren, useEffect } from 'react'; +import api from '../api/api'; +import { useAuth } from '../hooks/useAuth'; + +export const AppUserProvider = ({ children }: PropsWithChildren) => { + const { data: meData } = useQuery({ + queryFn: api.user.getMe, + queryKey: ['me'], + refetchOnWindowFocus: true, + throwOnError: false, + select: (resp) => resp.data, + }); + + useEffect(() => { + if (meData) { + useAuth.getState().setUser(meData); + } + }, [meData]); + + return {children}; +};