Merge branch 'dev' into feat/login-page-customization

This commit is contained in:
Fred KISSIE
2025-11-15 06:32:03 +01:00
64 changed files with 2824 additions and 406 deletions

View File

@@ -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",

View File

@@ -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"
}
"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"
}

View File

@@ -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);

223
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -19,6 +19,7 @@ export enum ActionsEnum {
getSite = "getSite",
listSites = "listSites",
updateSite = "updateSite",
reGenerateSecret = "reGenerateSecret",
createResource = "createResource",
deleteResource = "deleteResource",
getResource = "getResource",

View File

@@ -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: {

View File

@@ -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({})

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
);

View File

@@ -37,7 +37,7 @@ async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
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);

View File

@@ -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<any> {
try {

View File

@@ -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}`
}
);

View File

@@ -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";

View File

@@ -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<typeof reGenerateSecretBodySchema>;
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<any> {
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")
);
}
}

View File

@@ -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<any> {
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<UpdateRemoteExitNodeResponse>(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"
)
);
}
}

View File

@@ -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<any> {
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")
);
}
}

View File

@@ -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
};
}

View File

@@ -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()

View File

@@ -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,

View File

@@ -6,6 +6,11 @@ export type CreateRemoteExitNodeResponse = {
secret: string;
};
export type UpdateRemoteExitNodeResponse = {
remoteExitNodeId: string;
secret: string;
}
export type PickRemoteExitNodeDefaultsResponse = {
remoteExitNodeId: string;
secret: string;

View File

@@ -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)

View File

@@ -5,4 +5,4 @@ export * from "./updateSite";
export * from "./listSites";
export * from "./listSiteRoles";
export * from "./pickSiteDefaults";
export * from "./socketIntegration";
export * from "./socketIntegration";

View File

@@ -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());

View File

@@ -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: {

View File

@@ -232,6 +232,7 @@ export default function ExitNodesTable({
id: "actions",
cell: ({ row }) => {
const nodeRow = row.original;
const remoteExitNodeId = nodeRow.id;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
@@ -242,6 +243,14 @@ export default function ExitNodesTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedNode(nodeRow);
@@ -254,6 +263,14 @@ export default function ExitNodesTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${nodeRow.orgId}/settings/remote-exit-nodes/${remoteExitNodeId}`}
>
<Button variant={"secondary"} size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}

View File

@@ -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<PickRemoteExitNodeDefaultsResponse | null>(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<AxiosResponse<PickRemoteExitNodeDefaultsResponse>>(
`/org/${orgId}/pick-remote-exit-node-defaults`
);
const data = response.data.data;
setCredentials(data);
await api.put<AxiosResponse<QuickStartRemoteExitNodeResponse>>(
`/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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
</TooltipTrigger>
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
<RegenerateCredentialsModal
open={modalOpen}
onOpenChange={setModalOpen}
type="remote-exit-node"
onConfirmRegenerate={handleConfirmRegenerate}
dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()}
/>
</SettingsContainer>
);
}

View File

@@ -1,3 +0,0 @@
export default function GeneralPage() {
return <></>;
}

View File

@@ -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 (
<>
<SettingsSectionTitle
@@ -39,7 +48,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<RemoteExitNodeProvider remoteExitNode={remoteExitNode}>
<div className="space-y-6">{children}</div>
<div className="space-y-6">
<ExitNodeInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</RemoteExitNodeProvider>
</>
);

View File

@@ -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`
);
}

View File

@@ -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<PickClientDefaultsResponse | null>(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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}>
{t("regeneratecredentials")}
</Button>
</div>
</TooltipTrigger>
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
<RegenerateCredentialsModal
open={modalOpen}
onOpenChange={setModalOpen}
type="client-olm"
onConfirmRegenerate={handleConfirmRegenerate}
dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()}
/>
</SettingsContainer>
);
}

View File

@@ -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 (

View File

@@ -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() {
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("enterIdentifier")}
className="flex-1"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!resource.http && (
<>
<FormField

View File

@@ -512,9 +512,18 @@ export default function ReverseProxyTargets(props: {
port: target.port,
enabled: target.enabled,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath,
hcInterval: target.hcInterval,
hcTimeout: target.hcTimeout
hcPath: target.hcPath || null,
hcScheme: target.hcScheme || null,
hcHostname: target.hcHostname || null,
hcPort: target.hcPort || null,
hcInterval: target.hcInterval || null,
hcTimeout: target.hcTimeout || null,
hcHeaders: target.hcHeaders || null,
hcFollowRedirects: target.hcFollowRedirects || null,
hcMethod: target.hcMethod || null,
hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null
};
// Only include path-related fields for HTTP resources
@@ -718,7 +727,9 @@ export default function ReverseProxyTargets(props: {
hcHeaders: target.hcHeaders || null,
hcFollowRedirects: target.hcFollowRedirects || null,
hcMethod: target.hcMethod || 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
@@ -1814,6 +1825,7 @@ export default function ReverseProxyTargets(props: {
30
}}
onChanges={async (config) => {
console.log("here");
if (selectedTargetForHealthCheck) {
console.log(config);
updateTargetHealthCheck(

View File

@@ -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

View File

@@ -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<PickSiteDefaultsResponse | null>(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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("generatedcredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("regenerateCredentials")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
</TooltipTrigger>
{isSecurityFeatureDisabled() && (
<TooltipContent side="top">
{t("featureDisabledTooltip")}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</SettingsSectionBody>
</SettingsSection>
<RegenerateCredentialsModal
open={modalOpen}
onOpenChange={setModalOpen}
type={getCredentialType()}
onConfirmRegenerate={handleConfirmRegenerate}
dashboardUrl={env.app.dashboardUrl}
credentials={getCredentials()}
/>
</SettingsContainer>
);
}

View File

@@ -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<number | null>(
null
);
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(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" ? (
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("identifier")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("enterIdentifier")}
className="flex-1"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.flags.enableClients && site.type === "newt" ? (
<FormField
control={form.control}
name="remoteSubnets"

View File

@@ -8,6 +8,7 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteInfoCard from "../../../../../components/SiteInfoCard";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -35,14 +36,23 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t('general'),
href: "/{orgId}/settings/sites/{niceId}/general"
}
href: `/${params.orgId}/settings/sites/${params.niceId}/general`,
},
...(site.type !== 'local' && build === 'enterprise'
? [
{
title: t('credentials'),
href: `/${params.orgId}/settings/sites/${params.niceId}/credentials`,
},
]
: []),
];
return (
<>
<SettingsSectionTitle
title={t('siteSetting', {siteName: site?.name})}
title={t('siteSetting', { siteName: site?.name })}
description={t('siteSettingDescription')}
/>

View File

@@ -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"
/>
)}
<NextIntlClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeDataProvider colors={loadBrandingColors()}>
<EnvProvider env={pullEnv()}>
<LicenseStatusProvider
licenseStatus={licenseStatus}
>
<SupportStatusProvider
supporterStatus={supporterData}
<ReactQueryProvider>
<NextIntlClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeDataProvider colors={loadBrandingColors()}>
<EnvProvider env={pullEnv()}>
<LicenseStatusProvider
licenseStatus={licenseStatus}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<SplashImage>
<SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<SplashImage>
<LicenseViolation />
{children}
</SplashImage>
<LicenseViolation />
{children}
</SplashImage>
<LicenseViolation />
</div>
</div>
</div>
</SupportStatusProvider>
</LicenseStatusProvider>
<Toaster />
</EnvProvider>
</ThemeDataProvider>
</ThemeProvider>
</NextIntlClientProvider>
</SupportStatusProvider>
</LicenseStatusProvider>
<Toaster />
</EnvProvider>
</ThemeDataProvider>
</ThemeProvider>
</NextIntlClientProvider>
</ReactQueryProvider>
</body>
</html>
);

View File

@@ -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 (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
<AlertDescription className="mt-4">
<AlertDescription>
<InfoSections cols={2}>
<>
<InfoSection>

View File

@@ -278,14 +278,14 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<Link
className="block w-full"
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<DropdownMenuItem>
View settings
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);

View File

@@ -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<DNSRecordRow> = {
accessorKey: "verified",
header: ({ column }) => {
return <div>{t("status")}</div>;
},
cell: ({ row }) => {
const verified = row.original.verified;
return verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", { fallback: "Manual" })}
</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
);
}
};
const columns: ColumnDef<DNSRecordRow>[] = [
{
@@ -81,28 +106,7 @@ export default function DNSRecordsTable({
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return <div>{t("status")}</div>;
},
cell: ({ row }) => {
const verified = row.original.verified;
return verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", { fallback: "Manual" })}
</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
);
}
}
...(env.env.flags.usePangolinDns ? [statusColumn] : [])
];
return (

View File

@@ -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({
<span>{getTypeDisplay(type ? type : "")}</span>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{failed ? (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
) : verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
{env.env.flags.usePangolinDns && (
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{failed ? (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
) : verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
</Badge>
) : (
<Badge variant="green">
{t("verified")}
</Badge>
)
) : (
<Badge variant="green">
{t("verified")}
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
)}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>

View File

@@ -55,7 +55,8 @@ export default function DomainsTable({ domains, orgId }: Props) {
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
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<DomainRow> = {
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed, type } = row.original;
if (verified) {
return type == "wildcard" ? (
<Badge variant="outlinePrimary">{t("manual")}</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
);
} else if (failed) {
return (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
};
const columns: ColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
@@ -173,40 +209,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed, type } = row.original;
if (verified) {
return type == "wildcard" ? (
<Badge variant="outlinePrimary">{t("manual")}</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
);
} else if (failed) {
return (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
},
...(env.env.flags.usePangolinDns ? [statusColumn] : []),
{
id: "actions",
cell: ({ row }) => {

View File

@@ -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 (
<Alert>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{remoteExitNode.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>
{remoteExitNode.address}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -50,5 +50,11 @@ export function InfoSectionContent({
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("break-words", className)}>{children}</div>;
return (
<div className={cn("min-w-0 overflow-hidden", className)}>
<div className="w-full truncate [&>div.flex]:min-w-0 [&>div.flex]:!whitespace-normal [&>div.flex>span]:truncate [&>div.flex>a]:truncate">
{children}
</div>
</div>
);
}

View File

@@ -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({
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
@@ -114,7 +119,7 @@ export function LayoutSidebar({
>
<span
className={cn(
"flex-shrink-0",
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
@@ -133,7 +138,9 @@ export function LayoutSidebar({
</div>
</div>
<div className="p-4 space-y-4 shrink-0">
<div className="p-4 flex flex-col gap-4 shrink-0">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
{build === "enterprise" && (
<div className="mb-3">
<SidebarLicenseButton
@@ -148,7 +155,9 @@ export function LayoutSidebar({
)}
{build === "saas" && (
<div className="mb-3">
<SidebarSupportButton isCollapsed={isSidebarCollapsed} />
<SidebarSupportButton
isCollapsed={isSidebarCollapsed}
/>
</div>
)}
{!isSidebarCollapsed && (

View File

@@ -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 (
<div
className={cn(
"flex flex-col gap-2 overflow-clip",
isCollapsed && "hidden"
)}
>
<div className="flex flex-col gap-1">
<small
className={cn(
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
showMoreUpdatesText
? "animate-in fade-in duration-300"
: "opacity-0"
)}
>
{filteredUpdates.length > 0 && (
<>
<BellIcon className="flex-none size-3" />
<span>
{showNewVersionPopup
? t("productUpdateMoreInfo", {
noOfUpdates: filteredUpdates.length
})
: t("productUpdateInfo", {
noOfUpdates: filteredUpdates.length
})}
</span>
</>
)}
</small>
<ProductUpdatesListPopup
updates={filteredUpdates}
show={filteredUpdates.length > 0}
onDimissAll={() =>
setProductUpdatesRead([
...productUpdatesRead,
...filteredUpdates.map((update) => update.id)
])
}
onDimiss={(id) =>
setProductUpdatesRead([...productUpdatesRead, id])
}
/>
</div>
<NewVersionAvailable
version={data.latestVersion?.data}
onDimiss={() => {
setIgnoredVersionUpdate(
data.latestVersion?.data?.pangolin.latestVersion ?? null
);
}}
show={showNewVersionPopup}
/>
</div>
);
}
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 (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Transition show={showContent}>
<PopoverTrigger asChild>
<div
className={cn(
"relative z-1 cursor-pointer block",
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
"transition duration-300 ease-in-out",
"data-closed:opacity-0 data-closed:translate-y-full"
)}
>
<div className="rounded-md bg-muted-foreground/20 p-2">
<BellIcon className="flex-none size-4" />
</div>
<div className="flex flex-col gap-2 flex-1">
<div className="flex justify-between items-center w-full">
<p className="font-medium text-start">
{t("productUpdateWhatsNew")}
</p>
<div className="p-1 cursor-pointer ml-auto">
<ChevronRightIcon className="size-4 flex-none" />
</div>
</div>
<small
className={cn(
"text-start text-muted-foreground",
"overflow-hidden h-8",
"[-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]"
)}
>
{updates[0]?.contents}
</small>
</div>
</div>
</PopoverTrigger>
</Transition>
<PopoverContent
side="right"
align="end"
sideOffset={10}
className="p-0 flex flex-col w-85"
>
<div className="p-3 flex justify-between border-b items-center">
<span className="text-sm inline-flex gap-2 items-center font-medium">
{t("productUpdateTitle")}
{updates.length > 0 && (
<Badge variant="secondary">{updates.length}</Badge>
)}
</span>
<Button variant="outline" onClick={onDimissAll}>
{t("dismissAll")}
</Button>
</div>
<ol className="p-3 flex flex-col gap-1 max-h-112 overflow-y-auto">
{updates.length === 0 && (
<small className="border rounded-md flex p-4 border-dashed justify-center items-center text-muted-foreground">
{t("productUpdateEmpty")}
</small>
)}
{updates.map((update) => (
<li
key={update.id}
className="border rounded-md flex flex-col p-4 gap-2.5 group hover:bg-accent relative"
>
<div className="flex justify-between gap-2 items-start">
<h4 className="text-sm font-medium inline-flex items-start gap-1">
<span>{update.title}</span>
<Badge
variant={
update.type === "Important"
? "yellow"
: "secondary"
}
className={cn(
update.type === "New" &&
"bg-black text-white dark:bg-white dark:text-black"
)}
>
{update.type}
</Badge>
</h4>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
className="p-1 py-1 opacity-100 h-auto group-hover:opacity-100"
onClick={() =>
onDimiss(update.id)
}
>
<XIcon className="flex-none size-4" />
</Button>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
{t("dismiss")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-col gap-0.5">
<small className="text-muted-foreground">
{update.contents}{" "}
{update.link && (
<a
href={update.link}
target="_blank"
className="underline text-foreground inline-flex flex-wrap items-center gap-1 text-xs"
>
Read more{" "}
<ExternalLinkIcon className="size-3 flex-none" />
</a>
)}
</small>
</div>
<time
dateTime={update.publishedAt.toLocaleString()}
className="text-xs text-muted-foreground"
>
{timeAgoFormatter(update.publishedAt)}
</time>
</li>
))}
</ol>
</PopoverContent>
</Popover>
);
}
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 (
<Transition show={open}>
<div
className={cn(
"relative z-2",
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
"transition duration-300 ease-in-out",
"data-closed:opacity-0 data-closed:translate-y-full"
)}
>
{version && (
<>
<div className="rounded-md bg-muted-foreground/20 p-2">
<RocketIcon className="flex-none size-4" />
</div>
<div className="flex flex-col gap-2">
<p className="font-medium">
{t("pangolinUpdateAvailable")}
</p>
<small className="text-muted-foreground">
{t("pangolinUpdateAvailableInfo", {
version: version.pangolin.latestVersion
})}
</small>
<a
href={version.pangolin.releaseNotes}
target="_blank"
className="inline-flex items-center gap-0.5 text-xs font-medium"
>
<span>
{t("pangolinUpdateAvailableReleaseNotes")}
</span>
<ArrowRight className="flex-none size-3" />
</a>
</div>
<button
className="p-1 cursor-pointer"
onClick={() => {
setOpen(false);
onDimiss();
}}
>
<XIcon className="size-4 flex-none" />
</button>
</>
)}
</div>
</Transition>
);
}

View File

@@ -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<void>;
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 (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="max-h-[80vh] flex flex-col">
<CredenzaHeader>
<CredenzaTitle>{getTitle()}</CredenzaTitle>
<CredenzaDescription>{getDescription()}</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="overflow-y-auto px-4">
{stage === "confirm" ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("warning")}
</AlertTitle>
<AlertDescription>
{t("regenerateCredentialsConfirmation")}
</AlertDescription>
</Alert>
) : (
<>
{credentials?.wgConfig && (
<div className="space-y-4">
<div className="flex flex-col items-center gap-4">
<CopyTextBox text={credentials.wgConfig} />
<div className="relative w-fit border rounded-md">
<div className="bg-white p-6 rounded-md">
<QRCodeCanvas
value={credentials.wgConfig}
size={168}
className="mx-auto"
/>
</div>
</div>
</div>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("copyandsavethesecredentials")}
</AlertTitle>
<AlertDescription>
{t("copyandsavethesecredentialsdescription")}
</AlertDescription>
</Alert>
</div>
)}
{credentials?.Id && credentials.Secret && (
<div className="space-y-4">
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("endpoint")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={dashboardUrl} />
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("Id")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={credentials?.Id} />
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("SecretKey")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={credentials?.Secret} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("copyandsavethesecredentials")}
</AlertTitle>
<AlertDescription>
{t("copyandsavethesecredentialsdescription")}
</AlertDescription>
</Alert>
</div>
)}
</>
)}
</CredenzaBody>
<CredenzaFooter>
{stage === "confirm" ? (
<>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={handleConfirm}
loading={loading}
disabled={loading}
variant="destructive"
>
{t("confirm")}
</Button>
</>
) : (
<Button onClick={handleClose} className="w-full">
{t("close")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -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 (
<Alert>
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections
cols={resource.http && env.flags.usePangolinDns ? 4 : 3}
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
>
<InfoSection>
<InfoSectionTitle>
{t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
{resource.niceId}
</InfoSectionContent>
</InfoSection>
{resource.http ? (
<>
<InfoSection>
@@ -40,17 +49,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ||
authInfo.headerAuth ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ||
authInfo.headerAuth ? (
<div className="flex items-start space-x-2">
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<ShieldOff className="w-4 h-4 flex-shrink-0" />
<span>{t("notProtected")}</span>
</div>
)}
@@ -91,9 +100,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
</span>
{resource.protocol.toUpperCase()}
</InfoSectionContent>
</InfoSection>
<InfoSection>
@@ -155,11 +162,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enabled
? t("enabled")
: t("disabled")}
</span>
{resource.enabled ? (
<div className="flex items-center space-x-2">
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("enabled")}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
<span>{t("disabled")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>

View File

@@ -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 <CheckCircle2 className={`${iconClass} text-green-500`} />;
case 'degraded':
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case 'offline':
case "offline":
return <XCircle className={`${iconClass} text-destructive`} />;
case 'unknown':
return <Clock className={`${iconClass} text-gray-400`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
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<number>(() =>
getStoredPageSize('proxy-resources', 20)
getStoredPageSize("proxy-resources", 20)
);
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
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<VisibilityState>({});
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>({});
const [proxyColumnVisibility, setProxyColumnVisibility] =
useState<VisibilityState>({});
const [internalColumnVisibility, setInternalColumnVisibility] =
useState<VisibilityState>({});
const [proxyColumnFilters, setProxyColumnFilters] =
useState<ColumnFiltersState>([]);
@@ -427,24 +434,34 @@ export default function ResourcesTable({
return (
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm text-muted-foreground">No targets</span>
<span className="text-sm">
{t("resourcesTableNoTargets")}
</span>
</div>
);
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="flex items-center gap-2 h-8">
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === 'online' && 'Healthy'}
{overallStatus === 'degraded' && 'Degraded'}
{overallStatus === 'offline' && 'Offline'}
{overallStatus === 'unknown' && 'Unknown'}
{overallStatus === "online" && t("resourcesTableHealthy")}
{overallStatus === "degraded" && t("resourcesTableDegraded")}
{overallStatus === "offline" && t("resourcesTableOffline")}
{overallStatus === "unknown" && t("resourcesTableUnknown")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
@@ -453,16 +470,29 @@ export default function ResourcesTable({
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem key={target.targetId} className="flex items-center justify-between gap-4">
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={target.healthStatus === 'healthy' ? 'online' : 'offline'}
status={
target.healthStatus ===
"healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className={`capitalize ${target.healthStatus === 'healthy' ? 'text-green-500' : 'text-destructive'
}`}>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
@@ -472,13 +502,21 @@ export default function ResourcesTable({
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem key={target.targetId} className="flex items-center justify-between gap-4">
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon status="unknown" className="h-3 w-3" />
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled ? 'Disabled' : 'Not monitored'}
{!target.enabled
? t("disabled")
: t("resourcesTableNotMonitored")}
</span>
</DropdownMenuItem>
))}
@@ -489,7 +527,6 @@ export default function ResourcesTable({
);
}
const proxyColumns: ColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
@@ -507,28 +544,20 @@ export default function ResourcesTable({
);
}
},
{
accessorKey: "nice",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("resource")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "protocol",
header: t("protocol"),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.http ? (resourceRow.ssl ? "HTTPS" : "HTTP") : resourceRow.protocol.toUpperCase()}</span>;
return (
<span>
{resourceRow.http
? resourceRow.ssl
? "HTTPS"
: "HTTP"
: resourceRow.protocol.toUpperCase()}
</span>
);
}
},
{
@@ -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 (
<div>
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-green-500" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 text-yellow-500" />
<span>{t("notProtected")}</span>
</span>
) : (
@@ -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={
<div>
<p>
{t("resourceQuestionRemove")}
</p>
<p>
{t("resourceMessageRemove")}
</p>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
@@ -900,12 +930,8 @@ export default function ResourcesTable({
}}
dialog={
<div>
<p>
{t("resourceQuestionRemove")}
</p>
<p>
{t("resourceMessageRemove")}
</p>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
@@ -955,9 +981,7 @@ export default function ResourcesTable({
{t("refresh")}
</Button>
</div>
<div>
{getActionButton()}
</div>
<div>{getActionButton()}</div>
</div>
</CardHeader>
<CardContent>
@@ -976,12 +1000,12 @@ export default function ResourcesTable({
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
@@ -1039,7 +1063,9 @@ export default function ResourcesTable({
<div className="mt-4">
<DataTablePagination
table={proxyTable}
onPageSizeChange={handleProxyPageSizeChange}
onPageSizeChange={
handleProxyPageSizeChange
}
/>
</div>
</TabsContent>
@@ -1077,12 +1103,12 @@ export default function ResourcesTable({
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
@@ -1140,7 +1166,9 @@ export default function ResourcesTable({
<div className="mt-4">
<DataTablePagination
table={internalTable}
onPageSizeChange={handleInternalPageSizeChange}
onPageSizeChange={
handleInternalPageSizeChange
}
/>
</div>
</TabsContent>

View File

@@ -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 (
<Alert>
<AlertDescription>
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
<InfoSections cols={env.flags.enableClients ? 4 : 3}>
<InfoSection>
<InfoSectionTitle>
{t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
{site.niceId}
</InfoSectionContent>
</InfoSection>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>

View File

@@ -164,30 +164,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}
}
},
{
accessorKey: "nice",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="hidden md:flex whitespace-nowrap"
>
{t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<div className="hidden md:block whitespace-nowrap">
{row.original.nice}
</div>
);
}
},
{
accessorKey: "mbIn",
header: ({ column }) => {

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools position="bottom" />
</QueryClientProvider>
);
}

View File

@@ -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"
}
}
);

View File

@@ -0,0 +1,99 @@
import {
useState,
useEffect,
useCallback,
Dispatch,
SetStateAction
} from "react";
type SetValue<T> = Dispatch<SetStateAction<T>>;
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, SetValue<T>] {
// 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<T>(readValue);
// Return a wrapped version of useState's setter function that
// persists the new value to localStorage
const setValue: SetValue<T> = 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];
}

View File

@@ -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) {

View File

@@ -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,

13
src/lib/durationToMs.ts Normal file
View File

@@ -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];
}

View File

@@ -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: {

67
src/lib/queries.ts Normal file
View File

@@ -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<ResponseT<ProductUpdate[]>>(
`/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<ResponseT<LatestVersionResponse>>(
"/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
})
};

View File

@@ -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);
}

View File

@@ -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;
};
};