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 7c3844c9..f716b256 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", @@ -1525,6 +1534,12 @@ "resourcesTableTheseResourcesForUseWith": "These resources are for use with", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", + "resourcesTableNoTargets": "No targets", + "resourcesTableHealthy": "Healthy", + "resourcesTableDegraded": "Degraded", + "resourcesTableOffline": "Offline", + "resourcesTableUnknown": "Unknown", + "resourcesTableNotMonitored": "Not monitored", "editInternalResourceDialogEditClientResource": "Edit Client Resource", "editInternalResourceDialogUpdateResourceProperties": "Update the resource properties and target configuration for {resourceName}.", "editInternalResourceDialogResourceProperties": "Resource Properties", @@ -2115,5 +2130,31 @@ "selectedResources": "Selected Resources", "enableSelected": "Enable Selected", "disableSelected": "Disable Selected", - "checkSelectedStatus": "Check Status of Selected" -} \ No newline at end of file + "checkSelectedStatus": "Check Status of Selected", + "credentials": "Credentials", + "savecredentials": "Save Credentials", + "regeneratecredentials": "Re-key", + "regenerateCredentials": "Regenerate and save your credentials", + "generatedcredentials": "Generated Credentials", + "copyandsavethesecredentials": "Copy and save these credentials", + "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.", + "credentialsSaved" : "Credentials Saved", + "credentialsSavedDescription": "Credentials have been regenerated and saved successfully.", + "credentialsSaveError": "Credentials Save Error", + "credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.", + "regenerateCredentialsWarning": "Regenerating credentials will invalidate the previous ones. Make sure to update any configurations that use these credentials.", + "confirm": "Confirm", + "regenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials?", + "endpoint": "Endpoint", + "Id": "Id", + "SecretKey": "Secret Key", + "featureDisabledTooltip": "This feature is only available in the enterprise plan and require a license to use it.", + "niceId": "Nice ID", + "niceIdUpdated": "Nice ID Updated", + "niceIdUpdatedSuccessfully": "Nice ID Updated Successfully", + "niceIdUpdateError": "Error updating Nice ID", + "niceIdUpdateErrorDescription": "An error occurred while updating the Nice ID.", + "niceIdCannotBeEmpty": "Nice ID cannot be empty", + "enterIdentifier": "Enter identifier", + "identifier": "Identifier" +} diff --git a/next.config.mjs b/next.config.ts similarity index 67% rename from next.config.mjs rename to next.config.ts index d771dbca..a211a701 100644 --- a/next.config.mjs +++ b/next.config.ts @@ -1,14 +1,13 @@ +import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin(); -/** @type {import("next").NextConfig} */ -const nextConfig = { +const nextConfig: NextConfig = { eslint: { ignoreDuringBuilds: true }, - output: "standalone", - + 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 c679b6c8..90f8665d 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.0", "@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.16", "@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/auth/actions.ts b/server/auth/actions.ts index d08457e5..4608757b 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -19,6 +19,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + reGenerateSecret = "reGenerateSecret", createResource = "createResource", deleteResource = "deleteResource", getResource = "getResource", 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/lib/validators.ts b/server/lib/validators.ts index 59776105..db6ff26b 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -1,4 +1,5 @@ import z from "zod"; +import ipaddr from "ipaddr.js"; export function isValidCIDR(cidr: string): boolean { return z.string().cidr().safeParse(cidr).success; @@ -68,11 +69,11 @@ export function isUrlValid(url: string | undefined) { if (!url) return true; // the link is optional in the schema so if it's empty it's valid var pattern = new RegExp( "^(https?:\\/\\/)?" + // protocol - "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name - "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address - "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path - "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string - "(\\#[-a-z\\d_]*)?$", + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name + "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address + "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path + "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string + "(\\#[-a-z\\d_]*)?$", "i" ); return !!pattern.test(url); @@ -83,12 +84,15 @@ export function isTargetValid(value: string | undefined) { const DOMAIN_REGEX = /^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/; - const IPV4_REGEX = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; + // const IPV4_REGEX = + // /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + // const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; - if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { - return true; + try { + const addr = ipaddr.parse(value); + return addr.kind() === "ipv4" || addr.kind() === "ipv6"; + } catch { + // fall through to domain regex check } return DOMAIN_REGEX.test(value); @@ -169,10 +173,10 @@ export function isSecondLevelDomain(domain: string): boolean { } const trimmedDomain = domain.trim().toLowerCase(); - + // Split into parts const parts = trimmedDomain.split('.'); - + // Should have exactly 2 parts for a second-level domain (e.g., "example.com") if (parts.length !== 2) { return false; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 2a32d9ac..5fe8d538 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -23,11 +23,15 @@ import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; import * as logs from "#private/routers/auditLogs"; import * as misc from "#private/routers/misc"; +import * as reKey from "#private/routers/re-key"; import { verifyOrgAccess, verifyUserHasAction, - verifyUserIsServerAdmin + verifyUserIsServerAdmin, + verifySiteAccess, + verifyClientAccess, + verifyClientsEnabled, } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -430,3 +434,26 @@ authenticated.get( logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); + +authenticated.post( + "/re-key/:clientId/regenerate-client-secret", + verifyClientsEnabled, + verifyClientAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + reKey.reGenerateClientSecret +); + +authenticated.post( + "/re-key/:siteId/regenerate-site-secret", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + reKey.reGenerateSiteSecret +); + +authenticated.put( + "/re-key/:orgId/reGenerate-remote-exit-node-secret", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + reKey.reGenerateExitNodeSecret +); 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/private/routers/misc/sendSupportEmail.ts b/server/private/routers/misc/sendSupportEmail.ts index fef43ef8..9b3f9c14 100644 --- a/server/private/routers/misc/sendSupportEmail.ts +++ b/server/private/routers/misc/sendSupportEmail.ts @@ -68,7 +68,7 @@ export async function sendSupportEmail( { name: req.user?.email || "Support User", to: "support@pangolin.net", - from: req.user?.email || config.getNoReplyEmail(), + from: config.getNoReplyEmail(), subject: `Support Request: ${subject}` } ); diff --git a/server/private/routers/re-key/index.ts b/server/private/routers/re-key/index.ts new file mode 100644 index 00000000..41a1c967 --- /dev/null +++ b/server/private/routers/re-key/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./reGenerateClientSecret"; +export * from "./reGenerateSiteSecret"; +export * from "./reGenerateExitNodeSecret"; \ No newline at end of file diff --git a/server/private/routers/re-key/reGenerateClientSecret.ts b/server/private/routers/re-key/reGenerateClientSecret.ts new file mode 100644 index 00000000..e07099a4 --- /dev/null +++ b/server/private/routers/re-key/reGenerateClientSecret.ts @@ -0,0 +1,143 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, olms, } from "@server/db"; +import { clients } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { hashPassword } from "@server/auth/password"; + +const reGenerateSecretParamsSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const reGenerateSecretBodySchema = z + .object({ + olmId: z.string().min(1).optional(), + secret: z.string().min(1).optional(), + + }) + .strict(); + +export type ReGenerateSecretBody = z.infer; + +registry.registerPath({ + method: "post", + path: "/re-key/{clientId}/regenerate-client-secret", + description: "Regenerate a client's OLM credentials by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: reGenerateSecretParamsSchema, + body: { + content: { + "application/json": { + schema: reGenerateSecretBodySchema + } + } + } + }, + responses: {} +}); + + +export async function reGenerateClientSecret( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = reGenerateSecretBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { olmId, secret } = parsedBody.data; + + const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + let secretHash = undefined; + if (secret) { + secretHash = await hashPassword(secret); + } + + + // Fetch the client to make sure it exists and the user has access to it + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + const [existingOlm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + + if (existingOlm && olmId && secretHash) { + await db + .update(olms) + .set({ + olmId, + secretHash + }) + .where(eq(olms.clientId, clientId)); + } + + return response(res, { + data: existingOlm, + success: true, + error: false, + message: "Credentials regenerated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/re-key/reGenerateExitNodeSecret.ts b/server/private/routers/re-key/reGenerateExitNodeSecret.ts new file mode 100644 index 00000000..1503e75a --- /dev/null +++ b/server/private/routers/re-key/reGenerateExitNodeSecret.ts @@ -0,0 +1,129 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { remoteExitNodes } from "@server/db"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import { and, eq } from "drizzle-orm"; +import { UpdateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; +import { OpenAPITags, registry } from "@server/openApi"; + +export const paramsSchema = z.object({ + orgId: z.string() +}); + +const bodySchema = z + .object({ + remoteExitNodeId: z.string().length(15), + secret: z.string().length(48) + }) + .strict(); + + +registry.registerPath({ + method: "post", + path: "/re-key/{orgId}/regenerate-secret", + description: "Regenerate a exit node credentials by its org ID.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function reGenerateExitNodeSecret( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { remoteExitNodeId, secret } = parsedBody.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const [existingRemoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + if (!existingRemoteExitNode) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Remote Exit Node does not exist") + ); + } + + const secretHash = await hashPassword(secret); + + await db + .update(remoteExitNodes) + .set({ secretHash }) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + return response(res, { + data: { + remoteExitNodeId, + secret, + }, + success: true, + error: false, + message: "Remote Exit Node secret updated successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to update remoteExitNode", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update remoteExitNode" + ) + ); + } +} diff --git a/server/private/routers/re-key/reGenerateSiteSecret.ts b/server/private/routers/re-key/reGenerateSiteSecret.ts new file mode 100644 index 00000000..3826cbc3 --- /dev/null +++ b/server/private/routers/re-key/reGenerateSiteSecret.ts @@ -0,0 +1,168 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { hashPassword } from "@server/auth/password"; +import { addPeer } from "@server/routers/gerbil/peers"; + + +const updateSiteParamsSchema = z + .object({ + siteId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const updateSiteBodySchema = z + .object({ + type: z.enum(["newt", "wireguard"]), + newtId: z.string().min(1).max(255).optional(), + newtSecret: z.string().min(1).max(255).optional(), + exitNodeId: z.number().int().positive().optional(), + pubKey: z.string().optional(), + subnet: z.string().optional(), + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/re-key/{siteId}/regenerate-site-secret", + description: "Regenerate a site's Newt or WireGuard credentials by its site ID.", + tags: [OpenAPITags.Site], + request: { + params: updateSiteParamsSchema, + body: { + content: { + "application/json": { + schema: updateSiteBodySchema, + }, + }, + }, + }, + responses: {}, +}); + +export async function reGenerateSiteSecret( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()) + ); + } + + const parsedBody = updateSiteBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString()) + ); + } + + const { siteId } = parsedParams.data; + const { type, exitNodeId, pubKey, subnet, newtId, newtSecret } = parsedBody.data; + + let updatedSite = undefined; + + if (type === "newt") { + if (!newtSecret) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "newtSecret is required for newt sites") + ); + } + + const secretHash = await hashPassword(newtSecret); + + updatedSite = await db + .update(newts) + .set({ + newtId, + secretHash, + }) + .where(eq(newts.siteId, siteId)) + .returning(); + + logger.info(`Regenerated Newt credentials for site ${siteId}`); + + } else if (type === "wireguard") { + if (!pubKey) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Public key is required for wireguard sites") + ); + } + + if (!exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exit node ID is required for wireguard sites" + ) + ); + } + + try { + updatedSite = await db.transaction(async (tx) => { + await addPeer(exitNodeId, { + publicKey: pubKey, + allowedIps: subnet ? [subnet] : [], + }); + const result = await tx + .update(sites) + .set({ pubKey }) + .where(eq(sites.siteId, siteId)) + .returning(); + + return result; + }); + + logger.info(`Regenerated WireGuard credentials for site ${siteId}`); + } catch (err) { + logger.error( + `Transaction failed while regenerating WireGuard secret for site ${siteId}`, + err + ); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to regenerate WireGuard credentials. Rolled back transaction." + ) + ); + } + } + + return response(res, { + data: updatedSite, + success: true, + error: false, + message: "Credentials regenerated successfully", + status: HttpCode.OK, + }); + + } catch (error) { + logger.error("Unexpected error in reGenerateSiteSecret", error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An unexpected error occurred") + ); + } +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index eec83d0f..da5b0f18 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -60,6 +60,7 @@ type BasicUserData = { username: string; email: string | null; name: string | null; + role: string | null; }; export type VerifyUserResponse = { @@ -883,7 +884,8 @@ async function isUserAllowedToAccessResource( return { username: user.username, email: user.email, - name: user.name + name: user.name, + role: user.role }; } @@ -896,7 +898,8 @@ async function isUserAllowedToAccessResource( return { username: user.username, email: user.email, - name: user.name + name: user.name, + role: user.role }; } diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 884a9864..d458c4f8 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { Client, db, exitNodes, sites } from "@server/db"; +import { Client, db, exitNodes, olms, sites } from "@server/db"; import { clients, clientSites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -18,6 +18,7 @@ import { deletePeer as olmDeletePeer } from "../olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; +import { hashPassword } from "@server/auth/password"; const updateClientParamsSchema = z .object({ @@ -30,7 +31,7 @@ const updateClientSchema = z name: z.string().min(1).max(255).optional(), siteIds: z .array(z.number().int().positive()) - .optional() + .optional(), }) .strict(); @@ -89,6 +90,7 @@ export async function updateClient( const { clientId } = parsedParams.data; + // Fetch the client to make sure it exists and the user has access to it const [client] = await db .select() diff --git a/server/routers/external.ts b/server/routers/external.ts index 0099aeea..e8ecdbaa 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -178,6 +178,7 @@ authenticated.post( client.updateClient ); + // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, @@ -191,6 +192,7 @@ authenticated.post( logActionAudit(ActionsEnum.updateSite), site.updateSite ); + authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 55d0a286..ae0c2130 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -6,6 +6,11 @@ export type CreateRemoteExitNodeResponse = { secret: string; }; +export type UpdateRemoteExitNodeResponse = { + remoteExitNodeId: string; + secret: string; +} + export type PickRemoteExitNodeDefaultsResponse = { remoteExitNodeId: string; secret: string; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 13c5220d..04a57ec1 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -37,6 +37,7 @@ const updateResourceParamsSchema = z const updateHttpResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), + niceId: z.string().min(1).max(255).optional(), subdomain: subdomainSchema.nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), @@ -97,6 +98,7 @@ export type UpdateResourceResponse = Resource; const updateRawResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), + niceId: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), enabled: z.boolean().optional(), @@ -236,6 +238,30 @@ async function updateHttpResource( const updateData = parsedBody.data; + if (updateData.niceId) { + const [existingResource] = await db + .select() + .from(resources) + .where( + and( + eq(resources.niceId, updateData.niceId), + eq(resources.orgId, resource.orgId) + ) + ); + + if ( + existingResource && + existingResource.resourceId !== resource.resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A resource with niceId "${updateData.niceId}" already exists` + ) + ); + } + } + if (updateData.domainId) { const domainId = updateData.domainId; @@ -362,6 +388,30 @@ async function updateRawResource( const updateData = parsedBody.data; + if (updateData.niceId) { + const [existingResource] = await db + .select() + .from(resources) + .where( + and( + eq(resources.niceId, updateData.niceId), + eq(resources.orgId, resource.orgId) + ) + ); + + if ( + existingResource && + existingResource.resourceId !== resource.resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A resource with niceId "${updateData.niceId}" already exists` + ) + ); + } + } + const updatedResource = await db .update(resources) .set(updateData) diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 3edf67c1..b97557a8 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -5,4 +5,4 @@ export * from "./updateSite"; export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; -export * from "./socketIntegration"; +export * from "./socketIntegration"; \ No newline at end of file diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index e3724f36..2041420c 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { sites } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -20,6 +20,7 @@ const updateSiteParamsSchema = z const updateSiteBodySchema = z .object({ name: z.string().min(1).max(255).optional(), + niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), remoteSubnets: z .string() @@ -89,6 +90,29 @@ export async function updateSite( const { siteId } = parsedParams.data; const updateData = parsedBody.data; + // if niceId is provided, check if it's already in use by another site + if (updateData.niceId) { + const existingSite = await db + .select() + .from(sites) + .where( + and( + eq(sites.niceId, updateData.niceId), + eq(sites.orgId, sites.orgId) + ) + ) + .limit(1); + + if (existingSite.length > 0 && existingSite[0].siteId !== siteId) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A site with niceId "${updateData.niceId}" already exists` + ) + ); + } + } + // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs if (updateData.remoteSubnets) { const subnets = updateData.remoteSubnets.split(",").map((s) => s.trim()); 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/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index 6e9ab237..0834608d 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -232,6 +232,7 @@ export default function ExitNodesTable({ id: "actions", cell: ({ row }) => { const nodeRow = row.original; + const remoteExitNodeId = nodeRow.id; return (
@@ -242,6 +243,14 @@ export default function ExitNodesTable({ + + + {t("viewSettings")} + + { setSelectedNode(nodeRow); @@ -254,6 +263,14 @@ export default function ExitNodesTable({ + + +
); } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx new file mode 100644 index 00000000..115b1bd3 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams, useRouter } from "next/navigation"; +import { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { + PickRemoteExitNodeDefaultsResponse, + QuickStartRemoteExitNodeResponse +} from "@server/routers/remoteExitNode/types"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; +import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; + +export default function CredentialsPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const { remoteExitNode } = useRemoteExitNodeContext(); + + const [modalOpen, setModalOpen] = useState(false); + const [credentials, setCredentials] = useState(null); + + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscription?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; + + + const handleConfirmRegenerate = async () => { + + const response = await api.get>( + `/org/${orgId}/pick-remote-exit-node-defaults` + ); + + const data = response.data.data; + setCredentials(data); + + await api.put>( + `/re-key/${orgId}/reGenerate-remote-exit-node-secret`, + { + remoteExitNodeId: remoteExitNode.remoteExitNodeId, + secret: data.secret, + } + ); + + toast({ + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") + }); + + router.refresh(); + }; + + const getCredentials = () => { + if (credentials) { + return { + Id: remoteExitNode.remoteExitNodeId, + Secret: credentials.secret + }; + } + return undefined; + }; + + return ( + + + + + {t("generatedcredentials")} + + + {t("regenerateCredentials")} + + + + + + + +
+ +
+
+ + {isSecurityFeatureDisabled() && ( + + {t("featureDisabledTooltip")} + + )} +
+
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx deleted file mode 100644 index 191ce3f3..00000000 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function GeneralPage() { - return <>; -} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index 7a7b3611..19357a7f 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -6,6 +6,8 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import RemoteExitNodeProvider from "@app/providers/RemoteExitNodeProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import ExitNodeInfoCard from "@app/components/ExitNodeInfoCard"; interface SettingsLayoutProps { children: React.ReactNode; @@ -31,6 +33,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); + const navItems = [ + { + title: t('credentials'), + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" + } + ]; + return ( <> -
{children}
+
+ + {children} +
); diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx index 6b39c1de..5b9fd628 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -5,6 +5,6 @@ export default async function RemoteExitNodePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/general` + `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials` ); } diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx new file mode 100644 index 00000000..f14d49e4 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { PickClientDefaultsResponse } from "@server/routers/client"; +import { useClientContext } from "@app/hooks/useClientContext"; +import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal"; +import { build } from "@server/build"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; + +export default function CredentialsPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const { client } = useClientContext(); + + const [modalOpen, setModalOpen] = useState(false); + const [clientDefaults, setClientDefaults] = useState(null); + + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscription?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; + + + const handleConfirmRegenerate = async () => { + + const res = await api.get(`/org/${orgId}/pick-client-defaults`); + if (res && res.status === 200) { + const data = res.data.data; + setClientDefaults(data); + + await api.post(`/re-key/${client?.clientId}/regenerate-client-secret`, { + olmId: data.olmId, + secret: data.olmSecret, + }); + + toast({ + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") + }); + + router.refresh(); + } + }; + + const getCredentials = () => { + if (clientDefaults) { + return { + Id: clientDefaults.olmId, + Secret: clientDefaults.olmSecret + }; + } + return undefined; + }; + + return ( + + + + + {t("generatedcredentials")} + + + {t("regenerateCredentials")} + + + + + + + +
+ +
+
+ + {isSecurityFeatureDisabled() && ( + + {t("featureDisabledTooltip")} + + )} +
+
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx index c9c9fd14..257cb20f 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -7,6 +7,8 @@ import ClientInfoCard from "../../../../../components/ClientInfoCard"; import ClientProvider from "@app/providers/ClientProvider"; import { redirect } from "next/navigation"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; +import { build } from "@server/build"; type SettingsLayoutProps = { children: React.ReactNode; @@ -30,11 +32,20 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { redirect(`/${params.orgId}/settings/clients`); } + const t = await getTranslations(); + const navItems = [ { - title: "General", + title: t('general'), href: `/{orgId}/settings/clients/{clientId}/general` - } + }, + ...(build === 'enterprise' + ? [{ + title: t('credentials'), + href: `/{orgId}/settings/clients/{clientId}/credentials` + }, + ] + : []), ]; return ( diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx index 28f7754b..50155b3e 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx @@ -68,9 +68,9 @@ export default function GeneralForm() { const router = useRouter(); const t = useTranslations(); const [editDomainOpen, setEditDomainOpen] = useState(false); - const {licenseStatus } = useLicenseStatusContext(); + const { licenseStatus } = useLicenseStatusContext(); const subscriptionStatus = useSubscriptionStatusContext(); - const {user} = useUserContext(); + const { user } = useUserContext(); const { env } = useEnvContext(); @@ -102,6 +102,7 @@ export default function GeneralForm() { enabled: z.boolean(), subdomain: z.string().optional(), name: z.string().min(1).max(255), + niceId: z.string().min(1).max(255).optional(), domainId: z.string().optional(), proxyPort: z.number().int().min(1).max(65535).optional(), // enableProxy: z.boolean().optional() @@ -130,6 +131,7 @@ export default function GeneralForm() { defaultValues: { enabled: resource.enabled, name: resource.name, + niceId: resource.niceId, subdomain: resource.subdomain ? resource.subdomain : undefined, domainId: resource.domainId || undefined, proxyPort: resource.proxyPort || undefined, @@ -192,6 +194,7 @@ export default function GeneralForm() { { enabled: data.enabled, name: data.name, + niceId: data.niceId, subdomain: data.subdomain ? toASCII(data.subdomain) : undefined, domainId: data.domainId, proxyPort: data.proxyPort, @@ -212,16 +215,12 @@ export default function GeneralForm() { }); if (res && res.status === 200) { - toast({ - title: t("resourceUpdated"), - description: t("resourceUpdatedDescription") - }); - - const resource = res.data.data; + const updated = res.data.data; updateResource({ enabled: data.enabled, name: data.name, + niceId: data.niceId, subdomain: data.subdomain, fullDomain: resource.fullDomain, proxyPort: data.proxyPort, @@ -230,8 +229,20 @@ export default function GeneralForm() { // }) }); - router.refresh(); + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + + if (data.niceId && data.niceId !== resource?.niceId) { + router.replace(`/${updated.orgId}/settings/resources/${data.niceId}/general`); + } else { + router.refresh(); + } + + setSaveLoading(false); } + setSaveLoading(false); } @@ -304,6 +315,24 @@ export default function GeneralForm() { )} /> + ( + + {t("identifier")} + + + + + + )} + /> + {!resource.http && ( <> { + console.log("here"); if (selectedTargetForHealthCheck) { console.log(config); updateTargetHealthCheck( diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 1f98cf69..ae5e452d 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -574,7 +574,9 @@ export default function Page() { hcPort: target.hcPort || null, hcFollowRedirects: target.hcFollowRedirects || null, - hcStatus: target.hcStatus || null + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null }; // Only include path-related fields for HTTP resources diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx new file mode 100644 index 00000000..6dcee413 --- /dev/null +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { PickSiteDefaultsResponse } from "@server/routers/site"; +import { useSiteContext } from "@app/hooks/useSiteContext"; +import { generateKeypair } from "../wireguardConfig"; +import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { build } from "@server/build"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; + +export default function CredentialsPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const { site } = useSiteContext(); + + const [modalOpen, setModalOpen] = useState(false); + const [siteDefaults, setSiteDefaults] = useState(null); + const [wgConfig, setWgConfig] = useState(""); + const [publicKey, setPublicKey] = useState(""); + + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscription?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; + + + const hydrateWireGuardConfig = ( + privateKey: string, + publicKey: string, + subnet: string, + address: string, + endpoint: string, + listenPort: string + ) => { + const config = `[Interface] +Address = ${subnet} +ListenPort = 51820 +PrivateKey = ${privateKey} + +[Peer] +PublicKey = ${publicKey} +AllowedIPs = ${address.split("/")[0]}/32 +Endpoint = ${endpoint}:${listenPort} +PersistentKeepalive = 5`; + setWgConfig(config); + return config; + }; + + const handleConfirmRegenerate = async () => { + let generatedPublicKey = ""; + let generatedWgConfig = ""; + + if (site?.type === "wireguard") { + const generatedKeypair = generateKeypair(); + generatedPublicKey = generatedKeypair.publicKey; + setPublicKey(generatedPublicKey); + + const res = await api.get(`/org/${orgId}/pick-site-defaults`); + if (res && res.status === 200) { + const data = res.data.data; + setSiteDefaults(data); + + // generate config with the fetched data + generatedWgConfig = hydrateWireGuardConfig( + generatedKeypair.privateKey, + data.publicKey, + data.subnet, + data.address, + data.endpoint, + data.listenPort + ); + } + + await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, { + type: "wireguard", + subnet: res.data.data.subnet, + exitNodeId: res.data.data.exitNodeId, + pubKey: generatedPublicKey + }); + } + + if (site?.type === "newt") { + const res = await api.get(`/org/${orgId}/pick-site-defaults`); + if (res && res.status === 200) { + const data = res.data.data; + setSiteDefaults(data); + + await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, { + type: "newt", + newtId: data.newtId, + newtSecret: data.newtSecret + }); + } + } + + toast({ + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") + }); + + router.refresh(); + }; + + const getCredentialType = () => { + if (site?.type === "wireguard") return "site-wireguard"; + if (site?.type === "newt") return "site-newt"; + return "site-newt"; + }; + + const getCredentials = () => { + if (site?.type === "wireguard" && wgConfig) { + return { wgConfig }; + } + if (site?.type === "newt" && siteDefaults) { + return { + Id: siteDefaults.newtId, + Secret: siteDefaults.newtSecret + }; + } + return undefined; + }; + + return ( + + + + + {t("generatedcredentials")} + + + {t("regenerateCredentials")} + + + + + + + +
+ +
+
+ + {isSecurityFeatureDisabled() && ( + + {t("featureDisabledTooltip")} + + )} +
+
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 75406f48..eef981a8 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -15,7 +15,7 @@ import { import { Input } from "@/components/ui/input"; import { useSiteContext } from "@app/hooks/useSiteContext"; import { useForm } from "react-hook-form"; -import { toast } from "@app/hooks/useToast"; +import { toast, useToast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { SettingsContainer, @@ -37,6 +37,7 @@ import { Tag, TagInput } from "@app/components/tags/tag-input"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), + niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), remoteSubnets: z .array( @@ -55,19 +56,18 @@ export default function GeneralPage() { const { env } = useEnvContext(); const api = createApiClient(useEnvContext()); - - const [loading, setLoading] = useState(false); - const [activeCidrTagIndex, setActiveCidrTagIndex] = useState( - null - ); - const router = useRouter(); const t = useTranslations(); + const { toast } = useToast(); + + const [loading, setLoading] = useState(false); + const [activeCidrTagIndex, setActiveCidrTagIndex] = useState(null); const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: site?.name, + niceId: site?.niceId || "", dockerSocketEnabled: site?.dockerSocketEnabled ?? false, remoteSubnets: site?.remoteSubnets ? site.remoteSubnets.split(",").map((subnet, index) => ({ @@ -82,37 +82,40 @@ export default function GeneralPage() { async function onSubmit(data: GeneralFormValues) { setLoading(true); - await api - .post(`/site/${site?.siteId}`, { + try { + await api.post(`/site/${site?.siteId}`, { name: data.name, + niceId: data.niceId, dockerSocketEnabled: data.dockerSocketEnabled, remoteSubnets: data.remoteSubnets - ?.map((subnet) => subnet.text) - .join(",") || "" - }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("siteErrorUpdate"), - description: formatAxiosError( - e, - t("siteErrorUpdateDescription") - ) - }); + ?.map((subnet) => subnet.text) + .join(",") || "" }); - updateSite({ - name: data.name, - dockerSocketEnabled: data.dockerSocketEnabled, - remoteSubnets: - data.remoteSubnets?.map((subnet) => subnet.text).join(",") || "" - }); + updateSite({ + name: data.name, + niceId: data.niceId, + dockerSocketEnabled: data.dockerSocketEnabled, + remoteSubnets: + data.remoteSubnets?.map((subnet) => subnet.text).join(",") || "" + }); - toast({ - title: t("siteUpdated"), - description: t("siteUpdatedDescription") - }); + if (data.niceId && data.niceId !== site?.niceId) { + router.replace(`/${site?.orgId}/settings/sites/${data.niceId}/general`); + } + + toast({ + title: t("siteUpdated"), + description: t("siteUpdatedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteErrorUpdate"), + description: formatAxiosError(e, t("siteErrorUpdateDescription")) + }); + } setLoading(false); @@ -153,8 +156,25 @@ export default function GeneralPage() { )} /> - {env.flags.enableClients && - site.type === "newt" ? ( + ( + + {t("identifier")} + + + + + + )} + /> + + {env.flags.enableClients && site.type === "newt" ? ( 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/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index ec8ecacf..f8d96158 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -1,7 +1,6 @@ "use client"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { InfoIcon } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { useClientContext } from "@app/hooks/useClientContext"; import { InfoSection, @@ -19,9 +18,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { return ( - - {t("clientInformation")} - + <> diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx index 471cdf28..e5f6a006 100644 --- a/src/components/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -278,14 +278,14 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} + + + View settings + + { setSelectedClient(clientRow); diff --git a/src/components/DNSRecordTable.tsx b/src/components/DNSRecordTable.tsx index 37fa66c0..8c8f6bb4 100644 --- a/src/components/DNSRecordTable.tsx +++ b/src/components/DNSRecordTable.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Badge } from "@app/components/ui/badge"; import { DNSRecordsDataTable } from "./DNSRecordsDataTable"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { useEnvContext } from "@app/hooks/useEnvContext"; export type DNSRecordRow = { id: string; @@ -24,6 +25,30 @@ export default function DNSRecordsTable({ type }: Props) { const t = useTranslations(); + const env = useEnvContext(); + + const statusColumn: ColumnDef = { + accessorKey: "verified", + header: ({ column }) => { + return
{t("status")}
; + }, + cell: ({ row }) => { + const verified = row.original.verified; + return verified ? ( + type === "wildcard" ? ( + + {t("manual", { fallback: "Manual" })} + + ) : ( + {t("verified")} + ) + ) : ( + + {t("pending", { fallback: "Pending" })} + + ); + } + }; const columns: ColumnDef[] = [ { @@ -81,28 +106,7 @@ export default function DNSRecordsTable({ ); } }, - { - accessorKey: "verified", - header: ({ column }) => { - return
{t("status")}
; - }, - cell: ({ row }) => { - const verified = row.original.verified; - return verified ? ( - type === "wildcard" ? ( - - {t("manual", { fallback: "Manual" })} - - ) : ( - {t("verified")} - ) - ) : ( - - {t("pending", { fallback: "Pending" })} - - ); - } - } + ...(env.env.flags.usePangolinDns ? [statusColumn] : []) ]; return ( diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index b5de6cd3..e33998da 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -9,6 +9,7 @@ import { } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; import { Badge } from "./ui/badge"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type DomainInfoCardProps = { failed: boolean; @@ -22,6 +23,7 @@ export default function DomainInfoCard({ type }: DomainInfoCardProps) { const t = useTranslations(); + const env = useEnvContext(); const getTypeDisplay = (type: string) => { switch (type) { @@ -46,32 +48,34 @@ export default function DomainInfoCard({ {getTypeDisplay(type ? type : "")}
- - {t("status")} - - {failed ? ( - - {t("failed", { fallback: "Failed" })} - - ) : verified ? ( - type === "wildcard" ? ( - - {t("manual", { - fallback: "Manual" - })} + {env.env.flags.usePangolinDns && ( + + {t("status")} + + {failed ? ( + + {t("failed", { fallback: "Failed" })} + ) : verified ? ( + type === "wildcard" ? ( + + {t("manual", { + fallback: "Manual" + })} + + ) : ( + + {t("verified")} + + ) ) : ( - - {t("verified")} + + {t("pending", { fallback: "Pending" })} - ) - ) : ( - - {t("pending", { fallback: "Pending" })} - - )} - - + )} + + + )}
diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index 3e12bab5..a099a8b8 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -55,7 +55,8 @@ export default function DomainsTable({ domains, orgId }: Props) { const [restartingDomains, setRestartingDomains] = useState>( new Set() ); - const api = createApiClient(useEnvContext()); + const env = useEnvContext(); + const api = createApiClient(env); const router = useRouter(); const t = useTranslations(); const { toast } = useToast(); @@ -134,6 +135,41 @@ export default function DomainsTable({ domains, orgId }: Props) { } }; + const statusColumn: ColumnDef = { + accessorKey: "verified", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const { verified, failed, type } = row.original; + if (verified) { + return type == "wildcard" ? ( + {t("manual")} + ) : ( + {t("verified")} + ); + } else if (failed) { + return ( + + {t("failed", { fallback: "Failed" })} + + ); + } else { + return {t("pending")}; + } + } + }; + const columns: ColumnDef[] = [ { accessorKey: "baseDomain", @@ -173,40 +209,7 @@ export default function DomainsTable({ domains, orgId }: Props) { ); } }, - { - accessorKey: "verified", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const { verified, failed, type } = row.original; - if (verified) { - return type == "wildcard" ? ( - {t("manual")} - ) : ( - {t("verified")} - ); - } else if (failed) { - return ( - - {t("failed", { fallback: "Failed" })} - - ); - } else { - return {t("pending")}; - } - } - }, + ...(env.env.flags.usePangolinDns ? [statusColumn] : []), { id: "actions", cell: ({ row }) => { diff --git a/src/components/ExitNodeInfoCard.tsx b/src/components/ExitNodeInfoCard.tsx new file mode 100644 index 00000000..49ae1b61 --- /dev/null +++ b/src/components/ExitNodeInfoCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useTranslations } from "next-intl"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; + +type ExitNodeInfoCardProps = {}; + +export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) { + const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext(); + const t = useTranslations(); + + return ( + + + + <> + + {t("status")} + + {remoteExitNode.online ? ( +
+
+ {t("online")} +
+ ) : ( +
+
+ {t("offline")} +
+ )} +
+
+ + + {t("address")} + + {remoteExitNode.address} + + +
+
+
+ ); +} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index 41f5cd89..5959bfc3 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -50,5 +50,11 @@ export function InfoSectionContent({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return ( +
+
+ {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..ac2c22d8 --- /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/RegenerateCredentialsModal.tsx b/src/components/RegenerateCredentialsModal.tsx new file mode 100644 index 00000000..f485746b --- /dev/null +++ b/src/components/RegenerateCredentialsModal.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, AlertTriangle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { QRCodeCanvas } from "qrcode.react"; + +type CredentialType = "site-wireguard" | "site-newt" | "client-olm" | "remote-exit-node"; + +interface RegenerateCredentialsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: CredentialType; + onConfirmRegenerate: () => Promise; + dashboardUrl: string; + credentials?: { + // For WireGuard sites + wgConfig?: string; + + Id?: string; + Secret?: string; + }; +} + +export default function RegenerateCredentialsModal({ + open, + onOpenChange, + type, + onConfirmRegenerate, + dashboardUrl, + credentials +}: RegenerateCredentialsModalProps) { + const t = useTranslations(); + const [stage, setStage] = useState<"confirm" | "show">("confirm"); + const [loading, setLoading] = useState(false); + + const handleConfirm = async () => { + try { + setLoading(true); + await onConfirmRegenerate(); + setStage("show"); + } catch (error) { + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setStage("confirm"); + onOpenChange(false); + }; + + const getTitle = () => { + if (stage === "confirm") { + return t("regeneratecredentials"); + } + switch (type) { + case "site-wireguard": + return t("WgConfiguration"); + case "site-newt": + return t("siteNewtCredentials"); + case "client-olm": + return t("clientOlmCredentials"); + case "remote-exit-node": + return t("remoteExitNodeCreate.generate.title"); + } + }; + + const getDescription = () => { + if (stage === "confirm") { + return t("regenerateCredentialsWarning"); + } + switch (type) { + case "site-wireguard": + return t("WgConfigurationDescription"); + case "site-newt": + return t("siteNewtCredentialsDescription"); + case "client-olm": + return t("clientOlmCredentialsDescription"); + case "remote-exit-node": + return t("remoteExitNodeCreate.generate.description"); + } + }; + + return ( + + + + {getTitle()} + {getDescription()} + + + + {stage === "confirm" ? ( + + + + {t("warning")} + + + {t("regenerateCredentialsConfirmation")} + + + ) : ( + <> + {credentials?.wgConfig && ( +
+
+ +
+
+ +
+
+
+ + + + + {t("copyandsavethesecredentials")} + + + {t("copyandsavethesecredentialsdescription")} + + +
+ )} + + {credentials?.Id && credentials.Secret && ( +
+ + + + {t("endpoint")} + + + + + + + + {t("Id")} + + + + + + + + {t("SecretKey")} + + + + + + + + + + {t("copyandsavethesecredentials")} + + + {t("copyandsavethesecredentialsdescription")} + + +
+ + )} + + )} +
+ + + {stage === "confirm" ? ( + <> + + + + + + ) : ( + + )} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 50bb9af2..0696c605 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -1,7 +1,7 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react"; +import { ShieldCheck, ShieldOff, Eye, EyeOff } from "lucide-react"; import { useResourceContext } from "@app/hooks/useResourceContext"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { @@ -17,21 +17,30 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; type ResourceInfoBoxType = {}; -export default function ResourceInfoBox({}: ResourceInfoBoxType) { - const { resource, authInfo } = useResourceContext(); +export default function ResourceInfoBox({ }: ResourceInfoBoxType) { + const { resource, authInfo, updateResource } = useResourceContext(); const { env } = useEnvContext(); const t = useTranslations(); const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`; + return ( {/* 4 cols because of the certs */} + + + {t("identifier")} + + + {resource.niceId} + + {resource.http ? ( <> @@ -40,17 +49,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist || - authInfo.headerAuth ? ( -
- + authInfo.pincode || + authInfo.sso || + authInfo.whitelist || + authInfo.headerAuth ? ( +
+ {t("protected")}
) : (
- + {t("notProtected")}
)} @@ -91,9 +100,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {t("protocol")} - - {resource.protocol.toUpperCase()} - + {resource.protocol.toUpperCase()} @@ -155,11 +162,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {t("visibility")} - - {resource.enabled - ? t("enabled") - : t("disabled")} - + {resource.enabled ? ( +
+ + {t("enabled")} +
+ ) : ( +
+ + {t("disabled")} +
+ )}
diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 0e613da8..1cea3452 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -17,7 +17,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - DropdownMenuCheckboxItem, + DropdownMenuCheckboxItem } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { @@ -36,7 +36,7 @@ import { Wifi, WifiOff, CheckCircle2, - XCircle, + XCircle } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -75,13 +75,12 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import { Alert, AlertDescription } from "@app/components/ui/alert"; - export type TargetHealth = { targetId: number; ip: string; port: number; enabled: boolean; - healthStatus?: 'healthy' | 'unhealthy' | 'unknown'; + healthStatus?: "healthy" | "unhealthy" | "unknown"; }; export type ResourceRow = { @@ -102,45 +101,55 @@ export type ResourceRow = { targets?: TargetHealth[]; }; - -function getOverallHealthStatus(targets?: TargetHealth[]): 'online' | 'degraded' | 'offline' | 'unknown' { +function getOverallHealthStatus( + targets?: TargetHealth[] +): "online" | "degraded" | "offline" | "unknown" { if (!targets || targets.length === 0) { - return 'unknown'; + return "unknown"; } - const monitoredTargets = targets.filter(t => t.enabled && t.healthStatus && t.healthStatus !== 'unknown'); + const monitoredTargets = targets.filter( + (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" + ); if (monitoredTargets.length === 0) { - return 'unknown'; + return "unknown"; } - const healthyCount = monitoredTargets.filter(t => t.healthStatus === 'healthy').length; - const unhealthyCount = monitoredTargets.filter(t => t.healthStatus === 'unhealthy').length; + const healthyCount = monitoredTargets.filter( + (t) => t.healthStatus === "healthy" + ).length; + const unhealthyCount = monitoredTargets.filter( + (t) => t.healthStatus === "unhealthy" + ).length; if (healthyCount === monitoredTargets.length) { - return 'online'; + return "online"; } else if (unhealthyCount === monitoredTargets.length) { - return 'offline'; + return "offline"; } else { - return 'degraded'; + return "degraded"; } } -function StatusIcon({ status, className = "" }: { - status: 'online' | 'degraded' | 'offline' | 'unknown'; +function StatusIcon({ + status, + className = "" +}: { + status: "online" | "degraded" | "offline" | "unknown"; className?: string; }) { const iconClass = `h-4 w-4 ${className}`; switch (status) { - case 'online': + case "online": return ; - case 'degraded': + case "degraded": return ; - case 'offline': + case "offline": return ; - case 'unknown': - return ; + case "unknown": + return ; default: return null; } @@ -171,15 +180,14 @@ type ResourcesTableProps = { }; }; - const STORAGE_KEYS = { - PAGE_SIZE: 'datatable-page-size', + PAGE_SIZE: "datatable-page-size", getTablePageSize: (tableId?: string) => tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE }; const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { - if (typeof window === 'undefined') return defaultSize; + if (typeof window === "undefined") return defaultSize; try { const key = STORAGE_KEYS.getTablePageSize(tableId); @@ -191,24 +199,22 @@ const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { } } } catch (error) { - console.warn('Failed to read page size from localStorage:', error); + console.warn("Failed to read page size from localStorage:", error); } return defaultSize; }; const setStoredPageSize = (pageSize: number, tableId?: string): void => { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { const key = STORAGE_KEYS.getTablePageSize(tableId); localStorage.setItem(key, pageSize.toString()); } catch (error) { - console.warn('Failed to save page size to localStorage:', error); + console.warn("Failed to save page size to localStorage:", error); } }; - - export default function ResourcesTable({ resources, internalResources, @@ -224,12 +230,11 @@ export default function ResourcesTable({ const api = createApiClient({ env }); - const [proxyPageSize, setProxyPageSize] = useState(() => - getStoredPageSize('proxy-resources', 20) + getStoredPageSize("proxy-resources", 20) ); const [internalPageSize, setInternalPageSize] = useState(() => - getStoredPageSize('internal-resources', 20) + getStoredPageSize("internal-resources", 20) ); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -247,8 +252,10 @@ export default function ResourcesTable({ defaultSort ? [defaultSort] : [] ); - const [proxyColumnVisibility, setProxyColumnVisibility] = useState({}); - const [internalColumnVisibility, setInternalColumnVisibility] = useState({}); + const [proxyColumnVisibility, setProxyColumnVisibility] = + useState({}); + const [internalColumnVisibility, setInternalColumnVisibility] = + useState({}); const [proxyColumnFilters, setProxyColumnFilters] = useState([]); @@ -427,24 +434,34 @@ export default function ResourcesTable({ return (
- No targets + + {t("resourcesTableNoTargets")} +
); } - const monitoredTargets = targets.filter(t => t.enabled && t.healthStatus && t.healthStatus !== 'unknown'); - const unknownTargets = targets.filter(t => !t.enabled || !t.healthStatus || t.healthStatus === 'unknown'); + const monitoredTargets = targets.filter( + (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" + ); + const unknownTargets = targets.filter( + (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" + ); return ( - @@ -453,16 +470,29 @@ export default function ResourcesTable({ {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => ( - +
{`${target.ip}:${target.port}`}
- + {target.healthStatus}
@@ -472,13 +502,21 @@ export default function ResourcesTable({ {unknownTargets.length > 0 && ( <> {unknownTargets.map((target) => ( - +
- + {`${target.ip}:${target.port}`}
- {!target.enabled ? 'Disabled' : 'Not monitored'} + {!target.enabled + ? t("disabled") + : t("resourcesTableNotMonitored")}
))} @@ -489,7 +527,6 @@ export default function ResourcesTable({ ); } - const proxyColumns: ColumnDef[] = [ { accessorKey: "name", @@ -507,28 +544,20 @@ export default function ResourcesTable({ ); } }, - { - accessorKey: "nice", - header: ({ column }) => { - return ( - - ); - } - }, { accessorKey: "protocol", header: t("protocol"), cell: ({ row }) => { const resourceRow = row.original; - return {resourceRow.http ? (resourceRow.ssl ? "HTTPS" : "HTTP") : resourceRow.protocol.toUpperCase()}; + return ( + + {resourceRow.http + ? resourceRow.ssl + ? "HTTPS" + : "HTTP" + : resourceRow.protocol.toUpperCase()} + + ); } }, { @@ -554,7 +583,12 @@ export default function ResourcesTable({ sortingFn: (rowA, rowB) => { const statusA = getOverallHealthStatus(rowA.original.targets); const statusB = getOverallHealthStatus(rowB.original.targets); - const statusOrder = { online: 3, degraded: 2, offline: 1, unknown: 0 }; + const statusOrder = { + online: 3, + degraded: 2, + offline: 1, + unknown: 0 + }; return statusOrder[statusA] - statusOrder[statusB]; } }, @@ -605,13 +639,13 @@ export default function ResourcesTable({ return (
{resourceRow.authState === "protected" ? ( - - + + {t("protected")} ) : resourceRow.authState === "not_protected" ? ( - - + + {t("notProtected")} ) : ( @@ -857,12 +891,12 @@ export default function ResourcesTable({ const handleProxyPageSizeChange = (newPageSize: number) => { setProxyPageSize(newPageSize); - setStoredPageSize(newPageSize, 'proxy-resources'); + setStoredPageSize(newPageSize, "proxy-resources"); }; const handleInternalPageSizeChange = (newPageSize: number) => { setInternalPageSize(newPageSize); - setStoredPageSize(newPageSize, 'internal-resources'); + setStoredPageSize(newPageSize, "internal-resources"); }; return ( @@ -876,12 +910,8 @@ export default function ResourcesTable({ }} dialog={
-

- {t("resourceQuestionRemove")} -

-

- {t("resourceMessageRemove")} -

+

{t("resourceQuestionRemove")}

+

{t("resourceMessageRemove")}

} buttonText={t("resourceDeleteConfirm")} @@ -900,12 +930,8 @@ export default function ResourcesTable({ }} dialog={
-

- {t("resourceQuestionRemove")} -

-

- {t("resourceMessageRemove")} -

+

{t("resourceQuestionRemove")}

+

{t("resourceMessageRemove")}

} buttonText={t("resourceDeleteConfirm")} @@ -955,9 +981,7 @@ export default function ResourcesTable({ {t("refresh")}
-
- {getActionButton()} -
+
{getActionButton()}
@@ -976,12 +1000,12 @@ export default function ResourcesTable({ {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1039,7 +1063,9 @@ export default function ResourcesTable({
@@ -1077,12 +1103,12 @@ export default function ResourcesTable({ {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1140,7 +1166,9 @@ export default function ResourcesTable({
diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index 5eed91c5..6d35e145 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -1,7 +1,6 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { InfoIcon } from "lucide-react"; import { useSiteContext } from "@app/hooks/useSiteContext"; import { InfoSection, @@ -12,9 +11,10 @@ import { import { useTranslations } from "next-intl"; import { useEnvContext } from "@app/hooks/useEnvContext"; + type SiteInfoCardProps = {}; -export default function SiteInfoCard({}: SiteInfoCardProps) { +export default function SiteInfoCard({ }: SiteInfoCardProps) { const { site, updateSite } = useSiteContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -31,10 +31,19 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { } }; + return ( - + + + + {t("identifier")} + + + {site.niceId} + + {(site.type == "newt" || site.type == "wireguard") && ( <> diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 0f1b0536..0d8b161c 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -164,30 +164,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } } }, - { - accessorKey: "nice", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - return ( -
- {row.original.nice} -
- ); - } - }, { accessorKey: "mbIn", header: ({ column }) => { 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/hooks/useRemoteExitNodeContext.ts b/src/hooks/useRemoteExitNodeContext.ts index 486147c4..6fe244c8 100644 --- a/src/hooks/useRemoteExitNodeContext.ts +++ b/src/hooks/useRemoteExitNodeContext.ts @@ -2,11 +2,15 @@ import RemoteExitNodeContext from "@app/contexts/remoteExitNodeContext"; import { build } from "@server/build"; +import { GetRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { useContext } from "react"; export function useRemoteExitNodeContext() { if (build == "oss") { - return null; + return { + remoteExitNode: {} as GetRemoteExitNodeResponse, + updateRemoteExitNode: () => {}, + }; } const context = useContext(RemoteExitNodeContext); if (context === undefined) { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 6c4613e9..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, 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..19f1e212 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -21,7 +21,17 @@ export function pullEnv(): Env { 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 + dashboardUrl: process.env.DASHBOARD_URL as string, + notifications: { + product_updates: + process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED === "true" + ? true + : false, + new_releases: + process.env.NEW_RELEASES_NOTIFICATION_ENABLED === "true" + ? true + : false + } }, email: { emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false @@ -50,9 +60,7 @@ export function pullEnv(): Env { hideSupporterKey: process.env.HIDE_SUPPORTER_KEY === "true" ? true : false, usePangolinDns: - process.env.USE_PANGOLIN_DNS === "true" - ? true - : false + process.env.USE_PANGOLIN_DNS === "true" ? true : false }, branding: { 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; }; };