Merge pull request #1814 from Fredkiss3/feat/update-popup

Feat: version updates & product updates popup
This commit is contained in:
Milo Schwartz
2025-11-14 09:13:15 -08:00
committed by GitHub
24 changed files with 1175 additions and 167 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",

View File

@@ -1,14 +0,0 @@
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
/** @type {import("next").NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true
},
output: "standalone",
};
export default withNextIntl(nextConfig);

20
next.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import { pullEnv } from "./src/lib/pullEnv";
// validate env variables on local dev
if (process.env.NODE_ENV === "development") {
pullEnv();
}
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true
},
output: "standalone"
};
export default withNextIntl(nextConfig);

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.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "4.3.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tailwindcss/postcss": "^4.1.17",
"@types/better-sqlite3": "7.6.12",
"@types/cookie-parser": "1.4.10",
@@ -146,9 +149,9 @@
"@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/nprogress": "^0.2.3",
"@types/node": "24.9.2",
"@types/nodemailer": "7.0.3",
"@types/nprogress": "^0.2.3",
"@types/pg": "8.15.6",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",

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

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

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

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

@@ -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">
<div className="flex justify-between items-center">
<p className="font-medium text-start">
{t("productUpdateWhatsNew")}
</p>
<div className="p-1 cursor-pointer">
<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,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

@@ -51,6 +51,21 @@ export const internal = axios.create({
}
});
const remoteAPIURL =
process.env.NODE_ENV === "development"
? (process.env.NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL ??
"https://api.fossorial.io")
: "https://api.fossorial.io";
export const remote = axios.create({
baseURL: `${remoteAPIURL}/api/v1`,
timeout: 10000,
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": "x-csrf-protection"
}
});
export const priv = axios.create({
baseURL: `http://localhost:${process.env.SERVER_INTERNAL_PORT}/api/v1`,
timeout: 10000,
@@ -60,4 +75,3 @@ export const priv = axios.create({
});
export * from "./formatAxiosError";

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

@@ -1,105 +1,175 @@
import z from "zod";
import { Env } from "./types/env";
const envSchema = z.object({
// Server configuration
NEXT_PORT: z.string(),
SERVER_EXTERNAL_PORT: z.string(),
SESSION_COOKIE_NAME: z.string(),
RESOURCE_ACCESS_TOKEN_PARAM: z.string(),
RESOURCE_SESSION_REQUEST_PARAM: z.string(),
RESOURCE_ACCESS_TOKEN_HEADERS_ID: z.string(),
RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN: z.string(),
REO_CLIENT_ID: z.string().optional(),
MAXMIND_DB_PATH: z.string().optional(),
// App configuration
ENVIRONMENT: z.string(),
SANDBOX_MODE: z
.string()
.default("false")
.transform((val) => val === "true"),
APP_VERSION: z.string(),
DASHBOARD_URL: z.string(),
PRODUCT_UPDATES_NOTIFICATION_ENABLED: z
.string()
.default("true")
.transform((val) => val === "true"),
NEW_RELEASES_NOTIFICATION_ENABLED: z
.string()
.default("true")
.transform((val) => val === "true"),
// Email configuration
EMAIL_ENABLED: z
.string()
.default("false")
.transform((val) => val === "true"),
// Feature flags
DISABLE_USER_CREATE_ORG: z
.string()
.default("false")
.transform((val) => val === "true"),
DISABLE_SIGNUP_WITHOUT_INVITE: z
.string()
.default("false")
.transform((val) => val === "true"),
FLAGS_EMAIL_VERIFICATION_REQUIRED: z
.string()
.default("false")
.transform((val) => val === "true"),
FLAGS_ALLOW_RAW_RESOURCES: z
.string()
.default("false")
.transform((val) => val === "true"),
FLAGS_DISABLE_LOCAL_SITES: z
.string()
.default("false")
.transform((val) => val === "true"),
FLAGS_DISABLE_BASIC_WIREGUARD_SITES: z
.string()
.default("false")
.transform((val) => val === "true"),
FLAGS_ENABLE_CLIENTS: z
.string()
.default("false")
.transform((val) => val === "true"),
HIDE_SUPPORTER_KEY: z
.string()
.default("false")
.transform((val) => val === "true"),
USE_PANGOLIN_DNS: z
.string()
.default("false")
.transform((val) => val === "true"),
// Branding configuration (all optional)
BRANDING_APP_NAME: z.string().optional(),
BACKGROUND_IMAGE_PATH: z.string().optional(),
BRANDING_LOGO_LIGHT_PATH: z.string().optional(),
BRANDING_LOGO_DARK_PATH: z.string().optional(),
BRANDING_LOGO_AUTH_WIDTH: z.coerce.number().optional(),
BRANDING_LOGO_AUTH_HEIGHT: z.coerce.number().optional(),
BRANDING_LOGO_NAVBAR_WIDTH: z.coerce.number().optional(),
BRANDING_LOGO_NAVBAR_HEIGHT: z.coerce.number().optional(),
LOGIN_PAGE_TITLE_TEXT: z.string().optional(),
LOGIN_PAGE_SUBTITLE_TEXT: z.string().optional(),
SIGNUP_PAGE_TITLE_TEXT: z.string().optional(),
SIGNUP_PAGE_SUBTITLE_TEXT: z.string().optional(),
RESOURCE_AUTH_PAGE_SHOW_LOGO: z
.string()
.transform((val) => val === "true")
.optional(),
RESOURCE_AUTH_PAGE_HIDE_POWERED_BY: z
.string()
.transform((val) => val === "true")
.optional(),
RESOURCE_AUTH_PAGE_TITLE_TEXT: z.string().optional(),
RESOURCE_AUTH_PAGE_SUBTITLE_TEXT: z.string().optional(),
BRANDING_FOOTER: z.string().optional()
});
export function pullEnv(): Env {
const env = envSchema.parse(process.env);
return {
server: {
nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceAccessTokenParam: process.env
.RESOURCE_ACCESS_TOKEN_PARAM as string,
resourceSessionRequestParam: process.env
.RESOURCE_SESSION_REQUEST_PARAM as string,
resourceAccessTokenHeadersId: process.env
.RESOURCE_ACCESS_TOKEN_HEADERS_ID as string,
resourceAccessTokenHeadersToken: process.env
.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string,
reoClientId: process.env.REO_CLIENT_ID as string,
maxmind_db_path: process.env.MAXMIND_DB_PATH as string
nextPort: env.NEXT_PORT,
externalPort: env.SERVER_EXTERNAL_PORT,
sessionCookieName: env.SESSION_COOKIE_NAME,
resourceAccessTokenParam: env.RESOURCE_ACCESS_TOKEN_PARAM,
resourceSessionRequestParam: env.RESOURCE_SESSION_REQUEST_PARAM,
resourceAccessTokenHeadersId: env.RESOURCE_ACCESS_TOKEN_HEADERS_ID,
resourceAccessTokenHeadersToken:
env.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN,
reoClientId: env.REO_CLIENT_ID,
maxmind_db_path: env.MAXMIND_DB_PATH
},
app: {
environment: process.env.ENVIRONMENT as string,
sandbox_mode: process.env.SANDBOX_MODE === "true" ? true : false,
version: process.env.APP_VERSION as string,
dashboardUrl: process.env.DASHBOARD_URL as string
environment: env.ENVIRONMENT,
sandbox_mode: env.SANDBOX_MODE,
version: env.APP_VERSION,
dashboardUrl: env.DASHBOARD_URL,
notifications: {
product_updates: env.PRODUCT_UPDATES_NOTIFICATION_ENABLED,
new_releases: env.NEW_RELEASES_NOTIFICATION_ENABLED
}
},
email: {
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false
emailEnabled: env.EMAIL_ENABLED
},
flags: {
disableUserCreateOrg:
process.env.DISABLE_USER_CREATE_ORG === "true" ? true : false,
disableSignupWithoutInvite:
process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true"
? true
: false,
emailVerificationRequired:
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
? true
: false,
allowRawResources:
process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false,
disableLocalSites:
process.env.FLAGS_DISABLE_LOCAL_SITES === "true" ? true : false,
disableBasicWireguardSites:
process.env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES === "true"
? true
: false,
enableClients:
process.env.FLAGS_ENABLE_CLIENTS === "true" ? true : false,
hideSupporterKey:
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false,
usePangolinDns:
process.env.USE_PANGOLIN_DNS === "true"
? true
: false
disableUserCreateOrg: env.DISABLE_USER_CREATE_ORG,
disableSignupWithoutInvite: env.DISABLE_SIGNUP_WITHOUT_INVITE,
emailVerificationRequired: env.FLAGS_EMAIL_VERIFICATION_REQUIRED,
allowRawResources: env.FLAGS_ALLOW_RAW_RESOURCES,
disableLocalSites: env.FLAGS_DISABLE_LOCAL_SITES,
disableBasicWireguardSites: env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES,
enableClients: env.FLAGS_ENABLE_CLIENTS,
hideSupporterKey: env.HIDE_SUPPORTER_KEY,
usePangolinDns: env.USE_PANGOLIN_DNS
},
branding: {
appName: process.env.BRANDING_APP_NAME as string,
background_image_path: process.env.BACKGROUND_IMAGE_PATH as string,
appName: env.BRANDING_APP_NAME,
background_image_path: env.BACKGROUND_IMAGE_PATH,
logo: {
lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string,
darkPath: process.env.BRANDING_LOGO_DARK_PATH as string,
lightPath: env.BRANDING_LOGO_LIGHT_PATH,
darkPath: env.BRANDING_LOGO_DARK_PATH,
authPage: {
width: parseInt(
process.env.BRANDING_LOGO_AUTH_WIDTH as string
),
height: parseInt(
process.env.BRANDING_LOGO_AUTH_HEIGHT as string
)
width: env.BRANDING_LOGO_AUTH_WIDTH,
height: env.BRANDING_LOGO_AUTH_HEIGHT
},
navbar: {
width: parseInt(
process.env.BRANDING_LOGO_NAVBAR_WIDTH as string
),
height: parseInt(
process.env.BRANDING_LOGO_NAVBAR_HEIGHT as string
)
width: env.BRANDING_LOGO_NAVBAR_WIDTH,
height: env.BRANDING_LOGO_NAVBAR_HEIGHT
}
},
loginPage: {
titleText: process.env.LOGIN_PAGE_TITLE_TEXT as string,
subtitleText: process.env.LOGIN_PAGE_SUBTITLE_TEXT as string
titleText: env.LOGIN_PAGE_TITLE_TEXT,
subtitleText: env.LOGIN_PAGE_SUBTITLE_TEXT
},
signupPage: {
titleText: process.env.SIGNUP_PAGE_TITLE_TEXT as string,
subtitleText: process.env.SIGNUP_PAGE_SUBTITLE_TEXT as string
titleText: env.SIGNUP_PAGE_TITLE_TEXT,
subtitleText: env.SIGNUP_PAGE_SUBTITLE_TEXT
},
resourceAuthPage: {
showLogo:
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO === "true"
? true
: false,
hidePoweredBy:
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY === "true"
? true
: false,
titleText: process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT as string,
subtitleText: process.env
.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT as string
showLogo: env.RESOURCE_AUTH_PAGE_SHOW_LOGO,
hidePoweredBy: env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY,
titleText: env.RESOURCE_AUTH_PAGE_TITLE_TEXT,
subtitleText: env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT
},
footer: process.env.BRANDING_FOOTER as string
footer: env.BRANDING_FOOTER
}
};
}

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