diff --git a/messages/de-DE.json b/messages/de-DE.json index 001550d4..a729533d 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1021,7 +1021,7 @@ "actionDeleteSite": "Standort löschen", "actionGetSite": "Standort abrufen", "actionListSites": "Standorte auflisten", - "actionApplyBlueprint": "Blaupause anwenden", + "actionApplyBlueprint": "Blueprint anwenden", "setupToken": "Setup-Token", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", @@ -1080,11 +1080,11 @@ "actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen", "actionListIdpOrgs": "IDP-Organisationen auflisten", "actionUpdateIdpOrg": "IDP-Organisation aktualisieren", - "actionCreateClient": "Kunde erstellen", - "actionDeleteClient": "Kunde löschen", - "actionUpdateClient": "Kunde aktualisieren", - "actionListClients": "Kunden auflisten", - "actionGetClient": "Kunde holen", + "actionCreateClient": "Client erstellen", + "actionDeleteClient": "Client löschen", + "actionUpdateClient": "Client aktualisieren", + "actionListClients": "Clientsn auflisten", + "actionGetClient": "Client abrufen", "actionCreateSiteResource": "Site-Ressource erstellen", "actionDeleteSiteResource": "Site-Ressource löschen", "actionGetSiteResource": "Site-Ressource abrufen", @@ -1161,7 +1161,7 @@ "sidebarAllUsers": "Alle Benutzer", "sidebarIdentityProviders": "Identitätsanbieter", "sidebarLicense": "Lizenz", - "sidebarClients": "Kunden", + "sidebarClients": "Clients", "sidebarDomains": "Domänen", "sidebarBluePrints": "Baupläne", "blueprints": "Baupläne", @@ -1175,9 +1175,9 @@ "blueprintInfo": "Blaupauseninformation", "message": "Nachricht", "blueprintContentsDescription": "Definieren Sie den YAML-Inhalt, der Ihre Infrastruktur beschreibt", - "blueprintErrorCreateDescription": "Fehler beim Anwenden der Blaupause", - "blueprintErrorCreate": "Fehler beim Erstellen der Blaupause", - "searchBlueprintProgress": "Blaupausen suchen...", + "blueprintErrorCreateDescription": "Fehler beim Anwenden des Blueprints", + "blueprintErrorCreate": "Fehler beim Erstellen des Blueprints", + "searchBlueprintProgress": "Blueprints suchen...", "appliedAt": "Angewandt am", "source": "Quelle", "contents": "Inhalt", @@ -1423,14 +1423,14 @@ }, "siteRequired": "Standort ist erforderlich.", "olmTunnel": "Olm-Tunnel", - "olmTunnelDescription": "Nutzen Sie Olm für die Kundenverbindung", + "olmTunnelDescription": "Nutzen Sie Olm für die Clientverbindung", "errorCreatingClient": "Fehler beim Erstellen des Clients", "clientDefaultsNotFound": "Standardeinstellungen des Clients nicht gefunden", "createClient": "Client erstellen", "createClientDescription": "Erstellen Sie einen neuen Client für die Verbindung zu Ihren Standorten.", "seeAllClients": "Alle Clients anzeigen", - "clientInformation": "Kundeninformationen", - "clientNamePlaceholder": "Kundenname", + "clientInformation": "Clientninformationen", + "clientNamePlaceholder": "Clientname", "address": "Adresse", "subnetPlaceholder": "Subnetz", "addressDescription": "Die Adresse, die dieser Client für die Verbindung verwenden wird.", @@ -2049,7 +2049,7 @@ "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", "loadingDNSRecords": "Lade DNS-Einträge...", "olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.", - "client": "Kunde", + "client": "Client", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP/UDP-Dienste zu erhalten.", "enableProxyProtocol": "Proxy-Protokoll aktivieren", diff --git a/messages/en-US.json b/messages/en-US.json index 08d4e21a..dd2a2d3d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1279,6 +1279,15 @@ "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", "sidebarExpand": "Expand", + "productUpdateMoreInfo": "{noOfUpdates} more updates", + "productUpdateInfo": "{noOfUpdates} updates", + "productUpdateWhatsNew": "What's New", + "productUpdateTitle": "Product Updates", + "productUpdateEmpty": "No updates", + "dismissAll": "Dismiss all", + "pangolinUpdateAvailable": "New version available", + "pangolinUpdateAvailableInfo": "Version {version} is ready to install", + "pangolinUpdateAvailableReleaseNotes": "View release notes", "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index d771dbca..00000000 --- a/next.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import createNextIntlPlugin from "next-intl/plugin"; - -const withNextIntl = createNextIntlPlugin(); - -/** @type {import("next").NextConfig} */ -const nextConfig = { - eslint: { - ignoreDuringBuilds: true - }, - output: "standalone", - -}; - -export default withNextIntl(nextConfig); diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 00000000..e530fb7f --- /dev/null +++ b/next.config.ts @@ -0,0 +1,20 @@ +import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; + +import { pullEnv } from "./src/lib/pullEnv"; + +// validate env variables on local dev +if (process.env.NODE_ENV === "development") { + pullEnv(); +} + +const withNextIntl = createNextIntlPlugin(); + +const nextConfig: NextConfig = { + eslint: { + ignoreDuringBuilds: true + }, + output: "standalone" +}; + +export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index 2004db01..d190bbb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.922.0", "@faker-js/faker": "^10.1.0", + "@headlessui/react": "^2.2.9", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", @@ -41,6 +42,7 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "@tailwindcss/forms": "^0.5.10", + "@tanstack/react-query": "^5.90.6", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "^1.13.1", @@ -113,6 +115,7 @@ "@dotenvx/dotenvx": "1.51.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@react-email/preview-server": "4.3.2", + "@tanstack/react-query-devtools": "^5.90.2", "@tailwindcss/postcss": "^4.1.17", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.10", @@ -3129,6 +3132,21 @@ "@floating-ui/utils": "^0.2.10" } }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@floating-ui/react-dom": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", @@ -3208,6 +3226,26 @@ "tslib": "2" } }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@hexagon/base64": { "version": "1.1.28", "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", @@ -5999,6 +6037,73 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@react-email/body": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.1.0.tgz", @@ -7287,6 +7392,36 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -8457,6 +8592,61 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.6.tgz", + "integrity": "sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", + "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", + "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", + "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.90.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.2", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -8477,6 +8667,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "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" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -8490,6 +8697,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -21265,6 +21482,12 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", diff --git a/package.json b/package.json index 480da7e4..2b47c1cb 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.922.0", + "@faker-js/faker": "^10.1.0", + "@headlessui/react": "^2.2.9", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", @@ -63,6 +65,7 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "@tailwindcss/forms": "^0.5.10", + "@tanstack/react-query": "^5.90.6", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "^1.13.1", @@ -129,13 +132,13 @@ "yaml": "^2.8.1", "yargs": "18.0.0", "zod": "3.25.76", - "zod-validation-error": "3.5.2", - "@faker-js/faker": "^10.1.0" + "zod-validation-error": "3.5.2" }, "devDependencies": { "@dotenvx/dotenvx": "1.51.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@react-email/preview-server": "4.3.2", + "@tanstack/react-query-devtools": "^5.90.2", "@tailwindcss/postcss": "^4.1.17", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.10", @@ -146,9 +149,9 @@ "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.10", - "@types/nprogress": "^0.2.3", "@types/node": "24.9.2", "@types/nodemailer": "7.0.3", + "@types/nprogress": "^0.2.3", "@types/pg": "8.15.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", diff --git a/server/lib/config.ts b/server/lib/config.ts index 6cd3413e..b49814f0 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -89,6 +89,16 @@ export class Config { ? "true" : "false"; + process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app + .notifications.product_updates + ? "true" + : "false"; + + process.env.NEW_RELEASES_NOTIFICATION_ENABLED = parsedConfig.app + .notifications.new_releases + ? "true" + : "false"; + if (parsedConfig.server.maxmind_db_path) { process.env.MAXMIND_DB_PATH = parsedConfig.server.maxmind_db_path; } @@ -158,7 +168,7 @@ export class Config { try { const response = await fetch( - "https://api.fossorial.io/api/v1/license/validate", + `https://api.fossorial.io/api/v1/license/validate`, { method: "POST", headers: { diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 571708ef..9d6cafb9 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -31,6 +31,13 @@ export const configSchema = z anonymous_usage: z.boolean().optional().default(true) }) .optional() + .default({}), + notifications: z + .object({ + product_updates: z.boolean().optional().default(true), + new_releases: z.boolean().optional().default(true) + }) + .optional() .default({}) }) .optional() @@ -40,6 +47,10 @@ export const configSchema = z log_failed_attempts: false, telemetry: { anonymous_usage: true + }, + notifications: { + product_updates: true, + new_releases: true } }), domains: z @@ -205,7 +216,10 @@ export const configSchema = z .default(["newt", "wireguard", "local"]), allow_raw_resources: z.boolean().optional().default(true), file_mode: z.boolean().optional().default(false), - pp_transport_prefix: z.string().optional().default("pp-transport-v") + pp_transport_prefix: z + .string() + .optional() + .default("pp-transport-v") }) .optional() .default({}), @@ -315,8 +329,15 @@ export const configSchema = z nameservers: z .array(z.string().optional().optional()) .optional() - .default(["ns1.pangolin.net", "ns2.pangolin.net", "ns3.pangolin.net"]), - cname_extension: z.string().optional().default("cname.pangolin.net") + .default([ + "ns1.pangolin.net", + "ns2.pangolin.net", + "ns3.pangolin.net" + ]), + cname_extension: z + .string() + .optional() + .default("cname.pangolin.net") }) .optional() .default({}) diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index d3b0b9d6..9583cadd 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -188,7 +188,7 @@ class TelemetryClient { license_tier: licenseStatus.tier || "unknown" } }; - logger.debug("Sending enterprise startup telemtry payload:", { + logger.debug("Sending enterprise startup telemetry payload:", { payload }); // this.client.capture(payload); diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index 56d65a50..2c0c4420 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -37,7 +37,7 @@ async function createNewLicense(orgId: string, licenseData: any): Promise { const data = await response.json(); - logger.debug("Fossorial API response:", {data}); + logger.debug("Fossorial API response:", { data }); return data; } catch (error) { console.error("Error creating new license:", error); diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index f8da1b5a..fb54c763 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -17,7 +17,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; -import { GeneratedLicenseKey, ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; +import { + GeneratedLicenseKey, + ListGeneratedLicenseKeysResponse +} from "@server/routers/generatedLicense/types"; async function fetchLicenseKeys(orgId: string): Promise { try { diff --git a/server/routers/supporterKey/validateSupporterKey.ts b/server/routers/supporterKey/validateSupporterKey.ts index 9d949fb5..338c920e 100644 --- a/server/routers/supporterKey/validateSupporterKey.ts +++ b/server/routers/supporterKey/validateSupporterKey.ts @@ -5,10 +5,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { response as sendResponse } from "@server/lib/response"; -import { suppressDeprecationWarnings } from "moment"; import { supporterKey } from "@server/db"; import { db } from "@server/db"; -import { eq } from "drizzle-orm"; import config from "@server/lib/config"; const validateSupporterKeySchema = z @@ -44,7 +42,7 @@ export async function validateSupporterKey( const { githubUsername, key } = parsedBody.data; const response = await fetch( - "https://api.fossorial.io/api/v1/license/validate", + `https://api.fossorial.io/api/v1/license/validate`, { method: "POST", headers: { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cc586dfb..c8907a49 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,6 +20,7 @@ import { Toaster } from "@app/components/ui/toaster"; import { build } from "@server/build"; import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; +import { ReactQueryProvider } from "@app/components/react-query-provider"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -94,38 +95,40 @@ export default async function RootLayout({ strategy="afterInteractive" /> )} - - - - - - + + + + + - {/* Main content */} -
-
- + + {/* Main content */} +
+
+ + + {children} + - {children} - - +
-
- - - - - - - + + + + + + + + ); diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index a054e829..50a0c8e8 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -32,6 +32,11 @@ import { import { build } from "@server/build"; import SidebarLicenseButton from "./SidebarLicenseButton"; import { SidebarSupportButton } from "./SidebarSupportButton"; +import dynamic from "next/dynamic"; + +const ProductUpdates = dynamic(() => import("./ProductUpdates"), { + ssr: false +}); interface LayoutSidebarProps { orgId?: string; @@ -101,7 +106,7 @@ export function LayoutSidebar({ @@ -133,7 +138,9 @@ export function LayoutSidebar({
-
+
+ + {build === "enterprise" && (
- +
)} {!isSidebarCollapsed && ( diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx new file mode 100644 index 00000000..d2350118 --- /dev/null +++ b/src/components/ProductUpdates.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useLocalStorage } from "@app/hooks/useLocalStorage"; +import { cn } from "@app/lib/cn"; +import { + type LatestVersionResponse, + type ProductUpdate, + productUpdatesQueries +} from "@app/lib/queries"; +import { useQueries } from "@tanstack/react-query"; +import { + ArrowRight, + BellIcon, + ChevronRightIcon, + ExternalLinkIcon, + RocketIcon, + XIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Transition } from "@headlessui/react"; +import * as React from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; +import { timeAgoFormatter } from "@app/lib/timeAgoFormatter"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "./ui/tooltip"; + +export default function ProductUpdates({ + isCollapsed +}: { + isCollapsed?: boolean; +}) { + const { env } = useEnvContext(); + + const data = useQueries({ + queries: [ + productUpdatesQueries.list(env.app.notifications.product_updates), + productUpdatesQueries.latestVersion( + env.app.notifications.new_releases + ) + ], + combine(result) { + if (result[0].isLoading || result[1].isLoading) return null; + return { + updates: result[0].data?.data ?? [], + latestVersion: result[1].data + }; + } + }); + const t = useTranslations(); + const [showMoreUpdatesText, setShowMoreUpdatesText] = React.useState(false); + + // we delay the small text animation so that the user can notice it + React.useEffect(() => { + const timeout = setTimeout(() => setShowMoreUpdatesText(true), 600); + return () => clearTimeout(timeout); + }, []); + + const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage< + string | null + >("product-updates:skip-version", null); + + const [productUpdatesRead, setProductUpdatesRead] = useLocalStorage< + number[] + >("product-updates:read", []); + + if (!data) return null; + + const showNewVersionPopup = Boolean( + data?.latestVersion?.data && + ignoredVersionUpdate !== + data.latestVersion.data?.pangolin.latestVersion && + env.app.version !== data.latestVersion.data?.pangolin.latestVersion + ); + + const filteredUpdates = data.updates.filter( + (update) => !productUpdatesRead.includes(update.id) + ); + + return ( +
+
+ + {filteredUpdates.length > 0 && ( + <> + + + {showNewVersionPopup + ? t("productUpdateMoreInfo", { + noOfUpdates: filteredUpdates.length + }) + : t("productUpdateInfo", { + noOfUpdates: filteredUpdates.length + })} + + + )} + + 0} + onDimissAll={() => + setProductUpdatesRead([ + ...productUpdatesRead, + ...filteredUpdates.map((update) => update.id) + ]) + } + onDimiss={(id) => + setProductUpdatesRead([...productUpdatesRead, id]) + } + /> +
+ + { + setIgnoredVersionUpdate( + data.latestVersion?.data?.pangolin.latestVersion ?? null + ); + }} + show={showNewVersionPopup} + /> +
+ ); +} + +type ProductUpdatesListPopupProps = { + updates: ProductUpdate[]; + show: boolean; + onDimiss: (id: number) => void; + onDimissAll: () => void; +}; + +function ProductUpdatesListPopup({ + updates, + show, + onDimiss, + onDimissAll +}: ProductUpdatesListPopupProps) { + const [showContent, setShowContent] = React.useState(false); + const [popoverOpen, setPopoverOpen] = React.useState(false); + const t = useTranslations(); + + // we need to delay the initial opening state to have an animation on `appear` + React.useEffect(() => { + if (show) { + requestAnimationFrame(() => setShowContent(true)); + } + }, [show]); + + React.useEffect(() => { + if (updates.length === 0) { + setShowContent(false); + setPopoverOpen(false); + } + }, [updates.length]); + + return ( + + + +
+
+ +
+
+
+

+ {t("productUpdateWhatsNew")} +

+
+ +
+
+ + {updates[0]?.contents} + +
+
+
+
+ +
+ + {t("productUpdateTitle")} + {updates.length > 0 && ( + {updates.length} + )} + + +
+
    + {updates.length === 0 && ( + + {t("productUpdateEmpty")} + + )} + {updates.map((update) => ( +
  1. +
    +

    + {update.title} + + {update.type} + +

    + + + + + + + {t("dismiss")} + + + +
    +
    + + {update.contents}{" "} + {update.link && ( + + Read more{" "} + + + )} + +
    + +
  2. + ))} +
+
+
+ ); +} + +type NewVersionAvailableProps = { + onDimiss: () => void; + show: boolean; + version: LatestVersionResponse | null | undefined; +}; + +function NewVersionAvailable({ + version, + show, + onDimiss +}: NewVersionAvailableProps) { + const t = useTranslations(); + const [open, setOpen] = React.useState(false); + + // we need to delay the initial opening state to have an animation on `appear` + React.useEffect(() => { + if (show) { + requestAnimationFrame(() => setOpen(true)); + } + }, [show]); + + return ( + +
+ {version && ( + <> +
+ +
+
+

+ {t("pangolinUpdateAvailable")} +

+ + {t("pangolinUpdateAvailableInfo", { + version: version.pangolin.latestVersion + })} + + + + {t("pangolinUpdateAvailableReleaseNotes")} + + + +
+ + + )} +
+
+ ); +} diff --git a/src/components/react-query-provider.tsx b/src/components/react-query-provider.tsx new file mode 100644 index 00000000..0f65ba62 --- /dev/null +++ b/src/components/react-query-provider.tsx @@ -0,0 +1,29 @@ +"use client"; +import * as React from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { QueryClient } from "@tanstack/react-query"; + +export type ReactQueryProviderProps = { + children: React.ReactNode; +}; + +export function ReactQueryProvider({ children }: ReactQueryProviderProps) { + const [queryClient] = React.useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: 2, // retry twice by default + staleTime: 5 * 60 * 1_000 // 5 minutes + } + } + }) + ); + return ( + + {children} + + + ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 3bcf2bea..50ba04e0 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -10,7 +10,8 @@ const badgeVariants = cva( variant: { default: "border-transparent bg-primary text-primary-foreground", - outlinePrimary: "border-transparent bg-transparent border-primary text-primary", + outlinePrimary: + "border-transparent bg-transparent border-primary text-primary", secondary: "border-transparent bg-secondary text-secondary-foreground", destructive: @@ -18,12 +19,12 @@ const badgeVariants = cva( outline: "text-foreground", green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300", yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300", - red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300", - }, + red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300" + } }, defaultVariants: { - variant: "default", - }, + variant: "default" + } } ); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..e7fdc353 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,99 @@ +import { + useState, + useEffect, + useCallback, + Dispatch, + SetStateAction +} from "react"; + +type SetValue = Dispatch>; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, SetValue] { + // Get initial value from localStorage or use the provided initial value + const readValue = useCallback((): T => { + // Prevent build error "window is undefined" during SSR + if (typeof window === "undefined") { + return initialValue; + } + + try { + const item = window.localStorage.getItem(key); + return item ? (JSON.parse(item) as T) : initialValue; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }, [initialValue, key]); + + // State to store our value + const [storedValue, setStoredValue] = useState(readValue); + + // Return a wrapped version of useState's setter function that + // persists the new value to localStorage + const setValue: SetValue = useCallback( + (value) => { + // Prevent build error "window is undefined" during SSR + if (typeof window === "undefined") { + console.warn( + `Tried setting localStorage key "${key}" even though environment is not a client` + ); + } + + try { + // Allow value to be a function so we have the same API as useState + const newValue = + value instanceof Function ? value(storedValue) : value; + + // Save to local storage + window.localStorage.setItem(key, JSON.stringify(newValue)); + + // Save state + setStoredValue(newValue); + + // Dispatch a custom event so every useLocalStorage hook is notified + window.dispatchEvent(new Event("local-storage")); + } catch (error) { + console.warn(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + // Listen for changes to this key from other tabs/windows + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue !== null) { + try { + setStoredValue(JSON.parse(e.newValue)); + } catch (error) { + console.warn( + `Error parsing localStorage value for key "${key}":`, + error + ); + } + } + }; + + // Listen for storage events (changes from other tabs) + window.addEventListener("storage", handleStorageChange); + + // Listen for custom event (changes from same tab) + const handleLocalStorageChange = () => { + setStoredValue(readValue()); + }; + window.addEventListener("local-storage", handleLocalStorageChange); + + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener( + "local-storage", + handleLocalStorageChange + ); + }; + }, [key, readValue]); + + return [storedValue, setValue]; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5cef9f0e..14735053 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -51,6 +51,21 @@ export const internal = axios.create({ } }); +const remoteAPIURL = + process.env.NODE_ENV === "development" + ? (process.env.NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL ?? + "https://api.fossorial.io") + : "https://api.fossorial.io"; + +export const remote = axios.create({ + baseURL: `${remoteAPIURL}/api/v1`, + timeout: 10000, + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": "x-csrf-protection" + } +}); + export const priv = axios.create({ baseURL: `http://localhost:${process.env.SERVER_INTERNAL_PORT}/api/v1`, timeout: 10000, @@ -60,4 +75,3 @@ export const priv = axios.create({ }); export * from "./formatAxiosError"; - diff --git a/src/lib/durationToMs.ts b/src/lib/durationToMs.ts new file mode 100644 index 00000000..172bae15 --- /dev/null +++ b/src/lib/durationToMs.ts @@ -0,0 +1,13 @@ +export function durationToMs( + value: number, + unit: "seconds" | "minutes" | "hours" | "days" | "weeks" +): number { + const multipliers = { + seconds: 1000, + minutes: 60 * 1000, + hours: 60 * 60 * 1000, + days: 24 * 60 * 60 * 1000, + weeks: 7 * 24 * 60 * 60 * 1000 + }; + return value * multipliers[unit]; +} diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index c55f06fe..4b0567c8 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -1,105 +1,175 @@ +import z from "zod"; import { Env } from "./types/env"; +const envSchema = z.object({ + // Server configuration + NEXT_PORT: z.string(), + SERVER_EXTERNAL_PORT: z.string(), + SESSION_COOKIE_NAME: z.string(), + RESOURCE_ACCESS_TOKEN_PARAM: z.string(), + RESOURCE_SESSION_REQUEST_PARAM: z.string(), + RESOURCE_ACCESS_TOKEN_HEADERS_ID: z.string(), + RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN: z.string(), + REO_CLIENT_ID: z.string().optional(), + MAXMIND_DB_PATH: z.string().optional(), + + // App configuration + ENVIRONMENT: z.string(), + SANDBOX_MODE: z + .string() + .default("false") + .transform((val) => val === "true"), + APP_VERSION: z.string(), + DASHBOARD_URL: z.string(), + PRODUCT_UPDATES_NOTIFICATION_ENABLED: z + .string() + .default("true") + .transform((val) => val === "true"), + NEW_RELEASES_NOTIFICATION_ENABLED: z + .string() + .default("true") + .transform((val) => val === "true"), + + // Email configuration + EMAIL_ENABLED: z + .string() + .default("false") + .transform((val) => val === "true"), + + // Feature flags + DISABLE_USER_CREATE_ORG: z + .string() + .default("false") + .transform((val) => val === "true"), + DISABLE_SIGNUP_WITHOUT_INVITE: z + .string() + .default("false") + .transform((val) => val === "true"), + FLAGS_EMAIL_VERIFICATION_REQUIRED: z + .string() + .default("false") + .transform((val) => val === "true"), + FLAGS_ALLOW_RAW_RESOURCES: z + .string() + .default("false") + .transform((val) => val === "true"), + FLAGS_DISABLE_LOCAL_SITES: z + .string() + .default("false") + .transform((val) => val === "true"), + FLAGS_DISABLE_BASIC_WIREGUARD_SITES: z + .string() + .default("false") + .transform((val) => val === "true"), + FLAGS_ENABLE_CLIENTS: z + .string() + .default("false") + .transform((val) => val === "true"), + HIDE_SUPPORTER_KEY: z + .string() + .default("false") + .transform((val) => val === "true"), + USE_PANGOLIN_DNS: z + .string() + .default("false") + .transform((val) => val === "true"), + + // Branding configuration (all optional) + BRANDING_APP_NAME: z.string().optional(), + BACKGROUND_IMAGE_PATH: z.string().optional(), + BRANDING_LOGO_LIGHT_PATH: z.string().optional(), + BRANDING_LOGO_DARK_PATH: z.string().optional(), + BRANDING_LOGO_AUTH_WIDTH: z.coerce.number().optional(), + BRANDING_LOGO_AUTH_HEIGHT: z.coerce.number().optional(), + BRANDING_LOGO_NAVBAR_WIDTH: z.coerce.number().optional(), + BRANDING_LOGO_NAVBAR_HEIGHT: z.coerce.number().optional(), + LOGIN_PAGE_TITLE_TEXT: z.string().optional(), + LOGIN_PAGE_SUBTITLE_TEXT: z.string().optional(), + SIGNUP_PAGE_TITLE_TEXT: z.string().optional(), + SIGNUP_PAGE_SUBTITLE_TEXT: z.string().optional(), + RESOURCE_AUTH_PAGE_SHOW_LOGO: z + .string() + .transform((val) => val === "true") + .optional(), + RESOURCE_AUTH_PAGE_HIDE_POWERED_BY: z + .string() + .transform((val) => val === "true") + .optional(), + RESOURCE_AUTH_PAGE_TITLE_TEXT: z.string().optional(), + RESOURCE_AUTH_PAGE_SUBTITLE_TEXT: z.string().optional(), + BRANDING_FOOTER: z.string().optional() +}); + export function pullEnv(): Env { + const env = envSchema.parse(process.env); + return { server: { - nextPort: process.env.NEXT_PORT as string, - externalPort: process.env.SERVER_EXTERNAL_PORT as string, - sessionCookieName: process.env.SESSION_COOKIE_NAME as string, - resourceAccessTokenParam: process.env - .RESOURCE_ACCESS_TOKEN_PARAM as string, - resourceSessionRequestParam: process.env - .RESOURCE_SESSION_REQUEST_PARAM as string, - resourceAccessTokenHeadersId: process.env - .RESOURCE_ACCESS_TOKEN_HEADERS_ID as string, - resourceAccessTokenHeadersToken: process.env - .RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string, - reoClientId: process.env.REO_CLIENT_ID as string, - maxmind_db_path: process.env.MAXMIND_DB_PATH as string + nextPort: env.NEXT_PORT, + externalPort: env.SERVER_EXTERNAL_PORT, + sessionCookieName: env.SESSION_COOKIE_NAME, + resourceAccessTokenParam: env.RESOURCE_ACCESS_TOKEN_PARAM, + resourceSessionRequestParam: env.RESOURCE_SESSION_REQUEST_PARAM, + resourceAccessTokenHeadersId: env.RESOURCE_ACCESS_TOKEN_HEADERS_ID, + resourceAccessTokenHeadersToken: + env.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN, + reoClientId: env.REO_CLIENT_ID, + maxmind_db_path: env.MAXMIND_DB_PATH }, app: { - environment: process.env.ENVIRONMENT as string, - sandbox_mode: process.env.SANDBOX_MODE === "true" ? true : false, - version: process.env.APP_VERSION as string, - dashboardUrl: process.env.DASHBOARD_URL as string + environment: env.ENVIRONMENT, + sandbox_mode: env.SANDBOX_MODE, + version: env.APP_VERSION, + dashboardUrl: env.DASHBOARD_URL, + notifications: { + product_updates: env.PRODUCT_UPDATES_NOTIFICATION_ENABLED, + new_releases: env.NEW_RELEASES_NOTIFICATION_ENABLED + } }, email: { - emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false + emailEnabled: env.EMAIL_ENABLED }, flags: { - disableUserCreateOrg: - process.env.DISABLE_USER_CREATE_ORG === "true" ? true : false, - disableSignupWithoutInvite: - process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true" - ? true - : false, - emailVerificationRequired: - process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true" - ? true - : false, - allowRawResources: - process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false, - disableLocalSites: - process.env.FLAGS_DISABLE_LOCAL_SITES === "true" ? true : false, - disableBasicWireguardSites: - process.env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES === "true" - ? true - : false, - enableClients: - process.env.FLAGS_ENABLE_CLIENTS === "true" ? true : false, - hideSupporterKey: - process.env.HIDE_SUPPORTER_KEY === "true" ? true : false, - usePangolinDns: - process.env.USE_PANGOLIN_DNS === "true" - ? true - : false + disableUserCreateOrg: env.DISABLE_USER_CREATE_ORG, + disableSignupWithoutInvite: env.DISABLE_SIGNUP_WITHOUT_INVITE, + emailVerificationRequired: env.FLAGS_EMAIL_VERIFICATION_REQUIRED, + allowRawResources: env.FLAGS_ALLOW_RAW_RESOURCES, + disableLocalSites: env.FLAGS_DISABLE_LOCAL_SITES, + disableBasicWireguardSites: env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES, + enableClients: env.FLAGS_ENABLE_CLIENTS, + hideSupporterKey: env.HIDE_SUPPORTER_KEY, + usePangolinDns: env.USE_PANGOLIN_DNS }, - branding: { - appName: process.env.BRANDING_APP_NAME as string, - background_image_path: process.env.BACKGROUND_IMAGE_PATH as string, + appName: env.BRANDING_APP_NAME, + background_image_path: env.BACKGROUND_IMAGE_PATH, logo: { - lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string, - darkPath: process.env.BRANDING_LOGO_DARK_PATH as string, + lightPath: env.BRANDING_LOGO_LIGHT_PATH, + darkPath: env.BRANDING_LOGO_DARK_PATH, authPage: { - width: parseInt( - process.env.BRANDING_LOGO_AUTH_WIDTH as string - ), - height: parseInt( - process.env.BRANDING_LOGO_AUTH_HEIGHT as string - ) + width: env.BRANDING_LOGO_AUTH_WIDTH, + height: env.BRANDING_LOGO_AUTH_HEIGHT }, navbar: { - width: parseInt( - process.env.BRANDING_LOGO_NAVBAR_WIDTH as string - ), - height: parseInt( - process.env.BRANDING_LOGO_NAVBAR_HEIGHT as string - ) + width: env.BRANDING_LOGO_NAVBAR_WIDTH, + height: env.BRANDING_LOGO_NAVBAR_HEIGHT } }, loginPage: { - titleText: process.env.LOGIN_PAGE_TITLE_TEXT as string, - subtitleText: process.env.LOGIN_PAGE_SUBTITLE_TEXT as string + titleText: env.LOGIN_PAGE_TITLE_TEXT, + subtitleText: env.LOGIN_PAGE_SUBTITLE_TEXT }, signupPage: { - titleText: process.env.SIGNUP_PAGE_TITLE_TEXT as string, - subtitleText: process.env.SIGNUP_PAGE_SUBTITLE_TEXT as string + titleText: env.SIGNUP_PAGE_TITLE_TEXT, + subtitleText: env.SIGNUP_PAGE_SUBTITLE_TEXT }, resourceAuthPage: { - showLogo: - process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO === "true" - ? true - : false, - hidePoweredBy: - process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY === "true" - ? true - : false, - titleText: process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT as string, - subtitleText: process.env - .RESOURCE_AUTH_PAGE_SUBTITLE_TEXT as string + showLogo: env.RESOURCE_AUTH_PAGE_SHOW_LOGO, + hidePoweredBy: env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY, + titleText: env.RESOURCE_AUTH_PAGE_TITLE_TEXT, + subtitleText: env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT }, - footer: process.env.BRANDING_FOOTER as string + footer: env.BRANDING_FOOTER } }; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 00000000..3ddf32bf --- /dev/null +++ b/src/lib/queries.ts @@ -0,0 +1,67 @@ +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { durationToMs } from "./durationToMs"; +import { build } from "@server/build"; +import { remote } from "./api"; +import type ResponseT from "@server/types/Response"; + +export type ProductUpdate = { + link: string | null; + build: "enterprise" | "oss" | "saas" | null; + id: number; + type: "Update" | "Important" | "New" | "Warning"; + title: string; + contents: string; + publishedAt: Date; + showUntil: Date; +}; + +export type LatestVersionResponse = { + pangolin: { + latestVersion: string; + releaseNotes: string; + }; +}; + +export const productUpdatesQueries = { + list: (enabled: boolean) => + queryOptions({ + queryKey: ["PRODUCT_UPDATES"] as const, + queryFn: async ({ signal }) => { + const sp = new URLSearchParams({ + build + }); + const data = await remote.get>( + `/product-updates?${sp.toString()}`, + { signal } + ); + return data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(5, "minutes"); + } + return false; + }, + enabled + }), + latestVersion: (enabled: boolean) => + queryOptions({ + queryKey: ["LATEST_VERSION"] as const, + queryFn: async ({ signal }) => { + const data = await remote.get>( + "/versions", + { signal } + ); + return data.data; + }, + placeholderData: keepPreviousData, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "minutes"); + } + return false; + }, + enabled: enabled && (build === "oss" || build === "enterprise") // disabled in cloud version + // because we don't need to listen for new versions there + }) +}; diff --git a/src/lib/timeAgoFormatter.ts b/src/lib/timeAgoFormatter.ts new file mode 100644 index 00000000..f6ae0175 --- /dev/null +++ b/src/lib/timeAgoFormatter.ts @@ -0,0 +1,47 @@ +export function timeAgoFormatter( + dateInput: string | Date, + short: boolean = false +): string { + const date = new Date(dateInput); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + const secondsInMinute = 60; + const secondsInHour = 60 * secondsInMinute; + const secondsInDay = 24 * secondsInHour; + const secondsInWeek = 7 * secondsInDay; + const secondsInMonth = 30 * secondsInDay; + const secondsInYear = 365 * secondsInDay; + + let value: number; + let unit: Intl.RelativeTimeFormatUnit; + + if (diffInSeconds < secondsInMinute) { + value = diffInSeconds; + unit = "second"; + } else if (diffInSeconds < secondsInHour) { + value = Math.floor(diffInSeconds / secondsInMinute); + unit = "minute"; + } else if (diffInSeconds < secondsInDay) { + value = Math.floor(diffInSeconds / secondsInHour); + unit = "hour"; + } else if (diffInSeconds < secondsInWeek) { + value = Math.floor(diffInSeconds / secondsInDay); + unit = "day"; + } else if (diffInSeconds < secondsInMonth) { + value = Math.floor(diffInSeconds / secondsInWeek); + unit = "week"; + } else if (diffInSeconds < secondsInYear) { + value = Math.floor(diffInSeconds / secondsInMonth); + unit = "month"; + } else { + value = Math.floor(diffInSeconds / secondsInYear); + unit = "year"; + } + + const rtf = new Intl.RelativeTimeFormat(navigator.languages[0] ?? "en", { + numeric: "auto", + style: short ? "narrow" : "long" + }); + return rtf.format(-value, unit); +} diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 9ded37a0..ff2a67bf 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -4,6 +4,10 @@ export type Env = { sandbox_mode: boolean; version: string; dashboardUrl: string; + notifications: { + product_updates: boolean; + new_releases: boolean; + }; }; server: { externalPort: string; @@ -29,11 +33,11 @@ export type Env = { enableClients: boolean; hideSupporterKey: boolean; usePangolinDns: boolean; - }, + }; branding: { appName?: string; background_image_path?: string; - logo?: { + logo: { lightPath?: string; darkPath?: string; authPage?: { @@ -43,22 +47,22 @@ export type Env = { navbar?: { width?: number; height?: number; - } - }, - loginPage?: { + }; + }; + loginPage: { titleText?: string; subtitleText?: string; - }, - signupPage?: { + }; + signupPage: { titleText?: string; subtitleText?: string; - }, - resourceAuthPage?: { + }; + resourceAuthPage: { showLogo?: boolean; hidePoweredBy?: boolean; titleText?: string; subtitleText?: string; - }, + }; footer?: string; }; };