From 03e0e8d9c2cfa58537ea77284df9b09e4993fce5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 4 Nov 2025 13:57:55 +0100 Subject: [PATCH 01/70] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 55 ++++++++++++++++++++++ package.json | 8 ++-- src/app/layout.tsx | 61 +++++++++++++------------ src/components/LayoutSidebar.tsx | 15 ++++-- src/components/ProductUpdates.tsx | 20 ++++++++ src/components/react-query-provider.tsx | 29 ++++++++++++ 6 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 src/components/ProductUpdates.tsx create mode 100644 src/components/react-query-provider.tsx diff --git a/package-lock.json b/package-lock.json index 448f9c18..c924f98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "@tailwindcss/forms": "^0.5.10", + "@tanstack/react-query": "^5.90.6", + "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "^1.13.1", @@ -8492,6 +8494,59 @@ "tailwindcss": "4.1.16" } }, + "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==", + "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==", + "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", diff --git a/package.json b/package.json index be31b60f..a33df056 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.922.0", + "@faker-js/faker": "^10.1.0", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", @@ -63,6 +64,8 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "@tailwindcss/forms": "^0.5.10", + "@tanstack/react-query": "^5.90.6", + "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "^1.13.1", @@ -129,8 +132,7 @@ "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", @@ -146,9 +148,9 @@ "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.10", - "@types/nprogress": "^0.2.3", "@types/node": "24.9.2", "@types/nodemailer": "7.0.3", + "@types/nprogress": "^0.2.3", "@types/pg": "8.15.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cc586dfb..c8907a49 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,6 +20,7 @@ import { Toaster } from "@app/components/ui/toaster"; import { build } from "@server/build"; import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; +import { ReactQueryProvider } from "@app/components/react-query-provider"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -94,38 +95,40 @@ export default async function RootLayout({ strategy="afterInteractive" /> )} - - - - - - + + + + + - {/* Main content */} -
-
- + + {/* Main content */} +
+
+ + + {children} + - {children} - - +
-
- - - - - - - + + + + + + + + ); diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index a054e829..29ba342b 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -32,6 +32,7 @@ import { import { build } from "@server/build"; import SidebarLicenseButton from "./SidebarLicenseButton"; import { SidebarSupportButton } from "./SidebarSupportButton"; +import ProductUpdates from "./ProductUpdates"; interface LayoutSidebarProps { orgId?: string; @@ -101,7 +102,7 @@ export function LayoutSidebar({ @@ -133,7 +134,11 @@ export function LayoutSidebar({
-
+
+
+ +
+ {build === "enterprise" && (
- +
)} {!isSidebarCollapsed && ( diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx new file mode 100644 index 00000000..2c1806c5 --- /dev/null +++ b/src/components/ProductUpdates.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +interface ProductUpdatesSectionProps {} + +const data = {}; + +export default function ProductUpdates({}: ProductUpdatesSectionProps) { + const versions = useQuery({ + queryKey: [] + }); + return ( + <> + + 3 more updates + + + ); +} diff --git a/src/components/react-query-provider.tsx b/src/components/react-query-provider.tsx new file mode 100644 index 00000000..0f65ba62 --- /dev/null +++ b/src/components/react-query-provider.tsx @@ -0,0 +1,29 @@ +"use client"; +import * as React from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { QueryClient } from "@tanstack/react-query"; + +export type ReactQueryProviderProps = { + children: React.ReactNode; +}; + +export function ReactQueryProvider({ children }: ReactQueryProviderProps) { + const [queryClient] = React.useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: 2, // retry twice by default + staleTime: 5 * 60 * 1_000 // 5 minutes + } + } + }) + ); + return ( + + {children} + + + ); +} From a26a441d563c33ac1883b05de7414bfe3c48e88a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 06:54:56 +0100 Subject: [PATCH 02/70] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20validate=20env=20and?= =?UTF-8?q?=20add=20remote=20fossorial=20API=20as=20an=20env=20variable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.mjs => next.config.ts | 11 +- server/lib/config.ts | 4 +- .../generatedLicense/generateNewLicense.ts | 6 +- .../generatedLicense/listGeneratedLicenses.ts | 9 +- .../supporterKey/validateSupporterKey.ts | 4 +- src/lib/pullEnv.ts | 218 +++++++++++------- src/lib/types/env.ts | 21 +- 7 files changed, 176 insertions(+), 97 deletions(-) rename next.config.mjs => next.config.ts (50%) diff --git a/next.config.mjs b/next.config.ts similarity index 50% rename from next.config.mjs rename to next.config.ts index d771dbca..e70c4932 100644 --- a/next.config.mjs +++ b/next.config.ts @@ -1,14 +1,17 @@ +import { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +import { pullEnv } from "./src/lib/pullEnv"; +// validate env variables on build and such +pullEnv(); + const withNextIntl = createNextIntlPlugin(); -/** @type {import("next").NextConfig} */ -const nextConfig = { +const nextConfig: NextConfig = { eslint: { ignoreDuringBuilds: true }, - output: "standalone", - + output: "standalone" }; export default withNextIntl(nextConfig); diff --git a/server/lib/config.ts b/server/lib/config.ts index 6cd3413e..f71cfd51 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import { configSchema, readConfigFile } from "./readConfigFile"; import { fromError } from "zod-validation-error"; import { build } from "@server/build"; +import { pullEnv } from "@app/lib/pullEnv"; export class Config { private rawConfig!: z.infer; @@ -149,6 +150,7 @@ export class Config { public async checkSupporterKey() { const [key] = await db.select().from(supporterKey).limit(1); + const env = pullEnv(); if (!key) { return; @@ -158,7 +160,7 @@ export class Config { try { const response = await fetch( - "https://api.fossorial.io/api/v1/license/validate", + `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license/validate`, { method: "POST", headers: { diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index 56d65a50..dfbaaa89 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -18,11 +18,13 @@ import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; +import { pullEnv } from "@app/lib/pullEnv"; async function createNewLicense(orgId: string, licenseData: any): Promise { try { + const env = pullEnv(); const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`, + `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license-internal/enterprise/${orgId}/create`, { method: "PUT", headers: { @@ -37,7 +39,7 @@ async function createNewLicense(orgId: string, licenseData: any): Promise { const data = await response.json(); - logger.debug("Fossorial API response:", {data}); + logger.debug("Fossorial API response:", { data }); return data; } catch (error) { console.error("Error creating new license:", error); diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index f8da1b5a..839c8a2c 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -17,12 +17,17 @@ 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"; +import { pullEnv } from "@app/lib/pullEnv"; async function fetchLicenseKeys(orgId: string): Promise { try { + const env = pullEnv(); const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`, + `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license-internal/enterprise/${orgId}/list`, { method: "GET", headers: { diff --git a/server/routers/supporterKey/validateSupporterKey.ts b/server/routers/supporterKey/validateSupporterKey.ts index 9d949fb5..82315017 100644 --- a/server/routers/supporterKey/validateSupporterKey.ts +++ b/server/routers/supporterKey/validateSupporterKey.ts @@ -10,6 +10,7 @@ import { supporterKey } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import config from "@server/lib/config"; +import { pullEnv } from "@app/lib/pullEnv"; const validateSupporterKeySchema = z .object({ @@ -31,6 +32,7 @@ export async function validateSupporterKey( next: NextFunction ): Promise { try { + const env = pullEnv(); const parsedBody = validateSupporterKeySchema.safeParse(req.body); if (!parsedBody.success) { return next( @@ -44,7 +46,7 @@ export async function validateSupporterKey( const { githubUsername, key } = parsedBody.data; const response = await fetch( - "https://api.fossorial.io/api/v1/license/validate", + `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license/validate`, { method: "POST", headers: { diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index c55f06fe..7e5a0dc4 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -1,105 +1,169 @@ +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(), + NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL: z + .string() + .url() + .default("https://api.fossorial.io") + .transform((url) => url.replace(/(.*)\/?$/, "$1")), + + // 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, + fossorialRemoteAPIBaseUrl: env.NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL }, email: { - emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false + emailEnabled: env.EMAIL_ENABLED }, flags: { - disableUserCreateOrg: - process.env.DISABLE_USER_CREATE_ORG === "true" ? true : false, - disableSignupWithoutInvite: - process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true" - ? true - : false, - emailVerificationRequired: - process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true" - ? true - : false, - allowRawResources: - process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false, - disableLocalSites: - process.env.FLAGS_DISABLE_LOCAL_SITES === "true" ? true : false, - disableBasicWireguardSites: - process.env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES === "true" - ? true - : false, - enableClients: - process.env.FLAGS_ENABLE_CLIENTS === "true" ? true : false, - hideSupporterKey: - process.env.HIDE_SUPPORTER_KEY === "true" ? true : false, - usePangolinDns: - process.env.USE_PANGOLIN_DNS === "true" - ? true - : false + disableUserCreateOrg: env.DISABLE_USER_CREATE_ORG, + disableSignupWithoutInvite: env.DISABLE_SIGNUP_WITHOUT_INVITE, + emailVerificationRequired: env.FLAGS_EMAIL_VERIFICATION_REQUIRED, + allowRawResources: env.FLAGS_ALLOW_RAW_RESOURCES, + disableLocalSites: env.FLAGS_DISABLE_LOCAL_SITES, + disableBasicWireguardSites: env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES, + enableClients: env.FLAGS_ENABLE_CLIENTS, + hideSupporterKey: env.HIDE_SUPPORTER_KEY, + usePangolinDns: env.USE_PANGOLIN_DNS }, - branding: { - appName: process.env.BRANDING_APP_NAME as string, - background_image_path: process.env.BACKGROUND_IMAGE_PATH as string, + appName: env.BRANDING_APP_NAME, + background_image_path: env.BACKGROUND_IMAGE_PATH, logo: { - lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string, - darkPath: process.env.BRANDING_LOGO_DARK_PATH as string, + lightPath: env.BRANDING_LOGO_LIGHT_PATH, + darkPath: env.BRANDING_LOGO_DARK_PATH, authPage: { - width: parseInt( - process.env.BRANDING_LOGO_AUTH_WIDTH as string - ), - height: parseInt( - process.env.BRANDING_LOGO_AUTH_HEIGHT as string - ) + width: env.BRANDING_LOGO_AUTH_WIDTH, + height: env.BRANDING_LOGO_AUTH_HEIGHT }, navbar: { - width: parseInt( - process.env.BRANDING_LOGO_NAVBAR_WIDTH as string - ), - height: parseInt( - process.env.BRANDING_LOGO_NAVBAR_HEIGHT as string - ) + width: env.BRANDING_LOGO_NAVBAR_WIDTH, + height: env.BRANDING_LOGO_NAVBAR_HEIGHT } }, loginPage: { - titleText: process.env.LOGIN_PAGE_TITLE_TEXT as string, - subtitleText: process.env.LOGIN_PAGE_SUBTITLE_TEXT as string + titleText: env.LOGIN_PAGE_TITLE_TEXT, + subtitleText: env.LOGIN_PAGE_SUBTITLE_TEXT }, signupPage: { - titleText: process.env.SIGNUP_PAGE_TITLE_TEXT as string, - subtitleText: process.env.SIGNUP_PAGE_SUBTITLE_TEXT as string + titleText: env.SIGNUP_PAGE_TITLE_TEXT, + subtitleText: env.SIGNUP_PAGE_SUBTITLE_TEXT }, resourceAuthPage: { - showLogo: - process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO === "true" - ? true - : false, - hidePoweredBy: - process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY === "true" - ? true - : false, - titleText: process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT as string, - subtitleText: process.env - .RESOURCE_AUTH_PAGE_SUBTITLE_TEXT as string + showLogo: env.RESOURCE_AUTH_PAGE_SHOW_LOGO, + hidePoweredBy: env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY, + titleText: env.RESOURCE_AUTH_PAGE_TITLE_TEXT, + subtitleText: env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT }, - footer: process.env.BRANDING_FOOTER as string + footer: env.BRANDING_FOOTER } }; } diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 9ded37a0..64bc8d73 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -4,6 +4,7 @@ export type Env = { sandbox_mode: boolean; version: string; dashboardUrl: string; + fossorialRemoteAPIBaseUrl: string; }; server: { externalPort: string; @@ -29,11 +30,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 +44,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; }; }; From 2f1abfbef81bbbb381da821af629c57d03f85949 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 06:55:08 +0100 Subject: [PATCH 03/70] =?UTF-8?q?=F0=9F=9A=A7=20New=20version=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 + src/components/LayoutSidebar.tsx | 10 ++-- src/components/ProductUpdates.tsx | 91 ++++++++++++++++++++++++---- src/hooks/useLocalStorage.ts | 99 +++++++++++++++++++++++++++++++ src/lib/api/index.ts | 10 +++- src/lib/durationToMs.ts | 13 ++++ src/lib/queries.ts | 38 ++++++++++++ 7 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 src/hooks/useLocalStorage.ts create mode 100644 src/lib/durationToMs.ts create mode 100644 src/lib/queries.ts diff --git a/messages/en-US.json b/messages/en-US.json index 97272c6f..1b5c5e87 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1279,6 +1279,9 @@ "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", "sidebarExpand": "Expand", + "pangolinUpdateAvailable": "New version available", + "pangolinUpdateAvailableInfo": "Version {version} is ready to install", + "pangolinUpdateAvailableReleaseNotes": "View release notes", "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 29ba342b..32bae1e8 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -32,7 +32,11 @@ import { import { build } from "@server/build"; import SidebarLicenseButton from "./SidebarLicenseButton"; import { SidebarSupportButton } from "./SidebarSupportButton"; -import ProductUpdates from "./ProductUpdates"; +import dynamic from "next/dynamic"; + +const ProductUpdates = dynamic(() => import("./ProductUpdates"), { + ssr: false +}); interface LayoutSidebarProps { orgId?: string; @@ -135,9 +139,7 @@ export function LayoutSidebar({
-
- -
+ {build === "enterprise" && (
diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 2c1806c5..81f1fdb4 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -1,20 +1,87 @@ "use client"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useLocalStorage } from "@app/hooks/useLocalStorage"; +import { cn } from "@app/lib/cn"; +import { versionsQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, BellIcon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; -interface ProductUpdatesSectionProps {} +interface ProductUpdatesProps {} -const data = {}; - -export default function ProductUpdates({}: ProductUpdatesSectionProps) { - const versions = useQuery({ - queryKey: [] - }); +export default function ProductUpdates({}: ProductUpdatesProps) { return ( - <> - - 3 more updates - - +
+ {/* + + 3 more updates + */} + +
+ ); +} + +function NewVersionAvailable() { + const { env } = useEnvContext(); + const t = useTranslations(); + const { data: version } = useQuery(versionsQueries.latestVersion()); + + const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage< + string | null + >("ignored-version", null); + + const showNewVersionPopup = + version?.data && + ignoredVersionUpdate !== version.data.pangolin.latestVersion; + + return ( +
+ {version?.data && ( + <> +
+ +
+
+

+ {t("pangolinUpdateAvailable")} +

+ + {t("pangolinUpdateAvailableInfo", { + version: version.data.pangolin.latestVersion + })} + + + + {t("pangolinUpdateAvailableReleaseNotes")} + + + +
+ + + )} +
); } diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..e7fdc353 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,99 @@ +import { + useState, + useEffect, + useCallback, + Dispatch, + SetStateAction +} from "react"; + +type SetValue = Dispatch>; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, SetValue] { + // Get initial value from localStorage or use the provided initial value + const readValue = useCallback((): T => { + // Prevent build error "window is undefined" during SSR + if (typeof window === "undefined") { + return initialValue; + } + + try { + const item = window.localStorage.getItem(key); + return item ? (JSON.parse(item) as T) : initialValue; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }, [initialValue, key]); + + // State to store our value + const [storedValue, setStoredValue] = useState(readValue); + + // Return a wrapped version of useState's setter function that + // persists the new value to localStorage + const setValue: SetValue = useCallback( + (value) => { + // Prevent build error "window is undefined" during SSR + if (typeof window === "undefined") { + console.warn( + `Tried setting localStorage key "${key}" even though environment is not a client` + ); + } + + try { + // Allow value to be a function so we have the same API as useState + const newValue = + value instanceof Function ? value(storedValue) : value; + + // Save to local storage + window.localStorage.setItem(key, JSON.stringify(newValue)); + + // Save state + setStoredValue(newValue); + + // Dispatch a custom event so every useLocalStorage hook is notified + window.dispatchEvent(new Event("local-storage")); + } catch (error) { + console.warn(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + // Listen for changes to this key from other tabs/windows + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue !== null) { + try { + setStoredValue(JSON.parse(e.newValue)); + } catch (error) { + console.warn( + `Error parsing localStorage value for key "${key}":`, + error + ); + } + } + }; + + // Listen for storage events (changes from other tabs) + window.addEventListener("storage", handleStorageChange); + + // Listen for custom event (changes from same tab) + const handleLocalStorageChange = () => { + setStoredValue(readValue()); + }; + window.addEventListener("local-storage", handleLocalStorageChange); + + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener( + "local-storage", + handleLocalStorageChange + ); + }; + }, [key, readValue]); + + return [storedValue, setValue]; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5cef9f0e..a75d3abe 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -51,6 +51,15 @@ export const internal = axios.create({ } }); +export const remote = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL}/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 +69,3 @@ export const priv = axios.create({ }); export * from "./formatAxiosError"; - diff --git a/src/lib/durationToMs.ts b/src/lib/durationToMs.ts new file mode 100644 index 00000000..172bae15 --- /dev/null +++ b/src/lib/durationToMs.ts @@ -0,0 +1,13 @@ +export function durationToMs( + value: number, + unit: "seconds" | "minutes" | "hours" | "days" | "weeks" +): number { + const multipliers = { + seconds: 1000, + minutes: 60 * 1000, + hours: 60 * 60 * 1000, + days: 24 * 60 * 60 * 1000, + weeks: 7 * 24 * 60 * 60 * 1000 + }; + return value * multipliers[unit]; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 00000000..37ec0e76 --- /dev/null +++ b/src/lib/queries.ts @@ -0,0 +1,38 @@ +import { + type InfiniteData, + type QueryClient, + keepPreviousData, + queryOptions, + type skipToken +} 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 const versionsQueries = { + latestVersion: () => + queryOptions({ + queryKey: ["LATEST_VERSION"] as const, + queryFn: async ({ signal }) => { + const data = await remote.get< + ResponseT<{ + pangolin: { + latestVersion: string; + releaseNotes: string; + }; + }> + >("/latest-version"); + return data.data; + }, + placeholderData: keepPreviousData, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "minutes"); + } + return false; + }, + enabled: build === "oss" || build === "enterprise" // disabled in cloud version + // because we don't need to listen for new versions there + }) +}; From 162c6d567c300bbce9295bd75e0327eaf47c37a4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 07:26:41 +0100 Subject: [PATCH 04/70] =?UTF-8?q?=E2=8F=AA=20revert=20`package.json`=20cha?= =?UTF-8?q?nges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 55 ----------------------------------------------- package.json | 8 +++---- 2 files changed, 3 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index c924f98f..448f9c18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,6 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "@tailwindcss/forms": "^0.5.10", - "@tanstack/react-query": "^5.90.6", - "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "^1.13.1", @@ -8494,59 +8492,6 @@ "tailwindcss": "4.1.16" } }, - "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==", - "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==", - "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", diff --git a/package.json b/package.json index a33df056..be31b60f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.922.0", - "@faker-js/faker": "^10.1.0", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", @@ -64,8 +63,6 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "@tailwindcss/forms": "^0.5.10", - "@tanstack/react-query": "^5.90.6", - "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "^1.13.1", @@ -132,7 +129,8 @@ "yaml": "^2.8.1", "yargs": "18.0.0", "zod": "3.25.76", - "zod-validation-error": "3.5.2" + "zod-validation-error": "3.5.2", + "@faker-js/faker": "^10.1.0" }, "devDependencies": { "@dotenvx/dotenvx": "1.51.0", @@ -148,9 +146,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", From 44f419d4f743ef678d97466d726628218264309b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 07:30:01 +0100 Subject: [PATCH 05/70] =?UTF-8?q?=F0=9F=92=84=20animate=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 81f1fdb4..75590c84 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -7,13 +7,12 @@ import { versionsQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { ArrowRight, BellIcon, XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; interface ProductUpdatesProps {} export default function ProductUpdates({}: ProductUpdatesProps) { return ( -
+
{/* 3 more updates @@ -34,15 +33,16 @@ function NewVersionAvailable() { const showNewVersionPopup = version?.data && - ignoredVersionUpdate !== version.data.pangolin.latestVersion; + ignoredVersionUpdate !== version.data.pangolin.latestVersion && + env.app.version !== version.data.pangolin.latestVersion; + + if (!showNewVersionPopup) return null; return (
{version?.data && ( @@ -60,7 +60,7 @@ function NewVersionAvailable() { })} @@ -74,7 +74,7 @@ function NewVersionAvailable() { className="p-1 cursor-pointer" onClick={() => setIgnoredVersionUpdate( - version?.data?.pangolin.latestVersion ?? null + version.data?.pangolin.latestVersion ?? null ) } > From 18566c09dc37f6ebea3b8877e15516126dc83188 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 07:32:28 +0100 Subject: [PATCH 06/70] =?UTF-8?q?=E2=9E=95=20add=20tanstack=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 57 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 8 ++++--- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 448f9c18..54e1dfae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,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", @@ -114,6 +115,7 @@ "@esbuild-plugins/tsconfig-paths": "0.1.2", "@react-email/preview-server": "4.3.2", "@tailwindcss/postcss": "^4.1.16", + "@tanstack/react-query-devtools": "^5.90.2", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", @@ -8492,6 +8494,61 @@ "tailwindcss": "4.1.16" } }, + "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", diff --git a/package.json b/package.json index be31b60f..cac47634 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.922.0", + "@faker-js/faker": "^10.1.0", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", @@ -63,6 +64,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,14 +131,14 @@ "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", "@tailwindcss/postcss": "^4.1.16", + "@tanstack/react-query-devtools": "^5.90.2", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", @@ -146,9 +148,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", From a247ef7564085f7c7e5468b92a98b7a7f8ca8e50 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 07:33:25 +0100 Subject: [PATCH 07/70] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20import=20`type`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index e70c4932..3f4d320b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,4 @@ -import { NextConfig } from "next"; +import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; import { pullEnv } from "./src/lib/pullEnv"; From b9ce3165749143b501ee13f44a5c6b002425e9d0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 08:38:23 +0100 Subject: [PATCH 08/70] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + src/components/ProductUpdates.tsx | 58 +++++++++++++++------ src/lib/queries.ts | 84 +++++++++++++++++++------------ 3 files changed, 98 insertions(+), 46 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 1b5c5e87..3466edf4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1279,6 +1279,8 @@ "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", "sidebarExpand": "Expand", + "productUpdateMoreInfo": "{noOfUpdates} more updates", + "productUpdateInfo": "{noOfUpdates} updates", "pangolinUpdateAvailable": "New version available", "pangolinUpdateAvailableInfo": "Version {version} is ready to install", "pangolinUpdateAvailableReleaseNotes": "View release notes", diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 75590c84..0b9e35de 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -3,29 +3,59 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLocalStorage } from "@app/hooks/useLocalStorage"; import { cn } from "@app/lib/cn"; -import { versionsQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; +import { useQueries } from "@tanstack/react-query"; import { ArrowRight, BellIcon, XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -interface ProductUpdatesProps {} +export default function ProductUpdates() { + const data = useQueries({ + queries: [ + productUpdatesQueries.list, + productUpdatesQueries.latestVersion + ], + combine(result) { + return { + updates: result[0].data?.data ?? [], + latestVersion: result[1].data + }; + } + }); + + const t = useTranslations(); -export default function ProductUpdates({}: ProductUpdatesProps) { return ( -
- {/* - - 3 more updates - */} - +
+ {data.updates.length > 0 && ( + + + + {t("productUpdateMoreInfo", { + noOfUpdates: data.updates.length + })} + + + )} +
); } -function NewVersionAvailable() { +type NewVersionAvailableProps = { + version: + | Awaited< + ReturnType< + NonNullable< + typeof productUpdatesQueries.latestVersion.queryFn + > + > + > + | undefined; +}; + +function NewVersionAvailable({ version }: NewVersionAvailableProps) { const { env } = useEnvContext(); const t = useTranslations(); - const { data: version } = useQuery(versionsQueries.latestVersion()); const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage< string | null @@ -33,8 +63,8 @@ function NewVersionAvailable() { const showNewVersionPopup = version?.data && - ignoredVersionUpdate !== version.data.pangolin.latestVersion && - env.app.version !== version.data.pangolin.latestVersion; + ignoredVersionUpdate !== version.data?.pangolin.latestVersion && + env.app.version !== version.data?.pangolin.latestVersion; if (!showNewVersionPopup) return null; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 37ec0e76..9f1ca81c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,38 +1,58 @@ -import { - type InfiniteData, - type QueryClient, - keepPreviousData, - queryOptions, - type skipToken -} from "@tanstack/react-query"; +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 const versionsQueries = { - latestVersion: () => - queryOptions({ - queryKey: ["LATEST_VERSION"] as const, - queryFn: async ({ signal }) => { - const data = await remote.get< - ResponseT<{ - pangolin: { - latestVersion: string; - releaseNotes: string; - }; - }> - >("/latest-version"); - return data.data; - }, - placeholderData: keepPreviousData, - refetchInterval: (query) => { - if (query.state.data) { - return durationToMs(30, "minutes"); - } - return false; - }, - enabled: build === "oss" || build === "enterprise" // disabled in cloud version - // because we don't need to listen for new versions there - }) +type ProductUpdate = { + link: string | null; + edition: "enterprise" | "community" | "cloud" | null; + id: number; + priority: "CRITICAL" | "IMPORTANT" | "NORMAL" | null; + title: string; + contents: string; + publishedAt: Date; + showUntil: Date; +}; + +export const productUpdatesQueries = { + list: queryOptions({ + queryKey: ["PRODUCT_UPDATES"] as const, + queryFn: async ({ signal }) => { + const data = await remote.get>( + "/product-updates", + { signal } + ); + return data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(5, "minutes"); + } + return false; + } + }), + latestVersion: queryOptions({ + queryKey: ["LATEST_VERSION"] as const, + queryFn: async ({ signal }) => { + const data = await remote.get< + ResponseT<{ + pangolin: { + latestVersion: string; + releaseNotes: string; + }; + }> + >("/versions", { signal }); + return data.data; + }, + placeholderData: keepPreviousData, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "minutes"); + } + return false; + }, + enabled: build === "oss" || build === "enterprise" // disabled in cloud version + // because we don't need to listen for new versions there + }) }; From 6d349693a79feff99b6a01be5a46c72ba546b81c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 08:45:56 +0100 Subject: [PATCH 09/70] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 0b9e35de..5d56892b 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -55,6 +55,9 @@ type NewVersionAvailableProps = { function NewVersionAvailable({ version }: NewVersionAvailableProps) { const { env } = useEnvContext(); + console.log({ + env + }); const t = useTranslations(); const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage< From 030f90db2ec2ac8360d874350c6900fa00a269b3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 21:41:29 +0100 Subject: [PATCH 10/70] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20validate=20`env`=20v?= =?UTF-8?q?ariables=20only=20in=20DEV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/next.config.ts b/next.config.ts index 3f4d320b..e530fb7f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,8 +2,11 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; import { pullEnv } from "./src/lib/pullEnv"; -// validate env variables on build and such -pullEnv(); + +// validate env variables on local dev +if (process.env.NODE_ENV === "development") { + pullEnv(); +} const withNextIntl = createNextIntlPlugin(); From f371c7df81088e6011f3b713f26938228c219958 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 23:29:36 +0100 Subject: [PATCH 11/70] =?UTF-8?q?=E2=9E=95=20add=20headless/ui=20for=20bet?= =?UTF-8?q?ter=20enter/exit=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 166 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 167 insertions(+) diff --git a/package-lock.json b/package-lock.json index 54e1dfae..a22d552e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.922.0", "@faker-js/faker": "^10.1.0", + "@headlessui/react": "^2.2.9", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", @@ -3156,6 +3157,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", @@ -3235,6 +3251,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", @@ -6030,6 +6066,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", @@ -7318,6 +7421,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", @@ -8569,6 +8702,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", @@ -8582,6 +8732,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", @@ -21334,6 +21494,12 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", diff --git a/package.json b/package.json index cac47634..e07f5d91 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,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", From c64b102aaa35df453cb957007cd3e57fc4125fcf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 23:29:48 +0100 Subject: [PATCH 12/70] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LayoutSidebar.tsx | 2 +- src/components/ProductUpdates.tsx | 261 +++++++++++++++++++++--------- src/lib/queries.ts | 2 +- 3 files changed, 189 insertions(+), 76 deletions(-) diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 32bae1e8..50a0c8e8 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -139,7 +139,7 @@ export function LayoutSidebar({
- + {build === "enterprise" && (
diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 5d56892b..b0b3530a 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -3,45 +3,159 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLocalStorage } from "@app/hooks/useLocalStorage"; import { cn } from "@app/lib/cn"; -import { productUpdatesQueries } from "@app/lib/queries"; +import { type ProductUpdate, productUpdatesQueries } from "@app/lib/queries"; import { useQueries } from "@tanstack/react-query"; -import { ArrowRight, BellIcon, XIcon } from "lucide-react"; +import { ArrowRight, BellIcon, ChevronRightIcon, XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import { Transition } from "@headlessui/react"; +import * as React from "react"; -export default function ProductUpdates() { +export default function ProductUpdates({ + isCollapsed +}: { + isCollapsed?: boolean; +}) { const data = useQueries({ queries: [ productUpdatesQueries.list, productUpdatesQueries.latestVersion ], combine(result) { + if (result[0].isLoading || result[1].isLoading) return null; return { updates: result[0].data?.data ?? [], latestVersion: result[1].data }; } }); - + const { env } = useEnvContext(); const t = useTranslations(); + const [showMoreUpdatesText, setShowMoreUpdatesText] = React.useState(false); + + // we need to delay the initial + React.useEffect(() => { + const timeout = setTimeout(() => setShowMoreUpdatesText(true), 500); + return () => clearTimeout(timeout); + }, []); + + const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage< + string | null + >("ignored-version", null); + + const [showNewVersionPopup, setShowNewVersionPopup] = React.useState(true); + + if (!data) return null; + + // const showNewVersionPopup = Boolean( + // data?.latestVersion?.data && + // ignoredVersionUpdate !== + // data.latestVersion.data?.pangolin.latestVersion && + // env.app.version !== data.latestVersion.data?.pangolin.latestVersion + // ); return ( -
- {data.updates.length > 0 && ( - - - - {t("productUpdateMoreInfo", { - noOfUpdates: data.updates.length - })} - - +
+ > + { + // setIgnoredVersionUpdate( + // data.latestVersion?.data?.pangolin.latestVersion ?? null + // ); + setShowNewVersionPopup(false); + }} + show={showNewVersionPopup} + /> + + + + {data.updates.length > 0 && ( + <> + + + {showNewVersionPopup + ? t("productUpdateMoreInfo", { + noOfUpdates: data.updates.length + }) + : t("productUpdateInfo", { + noOfUpdates: data.updates.length + })} + + + )} + + + 0} + />
); } +type ProductUpdatesPopupProps = { updates: ProductUpdate[]; show: boolean }; + +function ProductUpdatesPopup({ updates, show }: ProductUpdatesPopupProps) { + const [open, setOpen] = 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(() => setOpen(true)); + } + }, [show]); + + return ( + +
+
+ +
+
+

What's new

+ + {updates[0].contents} + +
+ +
+
+ ); +} + type NewVersionAvailableProps = { + onClose: () => void; + show: boolean; version: | Awaited< ReturnType< @@ -49,72 +163,71 @@ type NewVersionAvailableProps = { typeof productUpdatesQueries.latestVersion.queryFn > > - > + >["data"] | undefined; }; -function NewVersionAvailable({ version }: NewVersionAvailableProps) { - const { env } = useEnvContext(); - console.log({ - env - }); +function NewVersionAvailable({ + version, + show, + onClose +}: NewVersionAvailableProps) { const t = useTranslations(); + const [open, setOpen] = React.useState(false); - const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage< - string | null - >("ignored-version", null); - - const showNewVersionPopup = - version?.data && - ignoredVersionUpdate !== version.data?.pangolin.latestVersion && - env.app.version !== version.data?.pangolin.latestVersion; - - if (!showNewVersionPopup) return null; + // we need to delay the initial opening state to have an animation on `appear` + React.useEffect(() => { + if (show) { + requestAnimationFrame(() => setOpen(true)); + } + }, [show]); return ( -
+ ); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 9f1ca81c..d8e4ee89 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,7 +4,7 @@ import { build } from "@server/build"; import { remote } from "./api"; import type ResponseT from "@server/types/Response"; -type ProductUpdate = { +export type ProductUpdate = { link: string | null; edition: "enterprise" | "community" | "cloud" | null; id: number; From 64b87e203a1a38d47c2fd84fd691dc74d5a84d23 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 23:57:43 +0100 Subject: [PATCH 13/70] =?UTF-8?q?=F0=9F=92=84=20animate=20product=20update?= =?UTF-8?q?s=20&=20new=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/components/ProductUpdates.tsx | 101 ++++++++++++++++-------------- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3466edf4..d5521aec 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1281,6 +1281,7 @@ "sidebarExpand": "Expand", "productUpdateMoreInfo": "{noOfUpdates} more updates", "productUpdateInfo": "{noOfUpdates} updates", + "productUpdateWhatsNew": "What's New", "pangolinUpdateAvailable": "New version available", "pangolinUpdateAvailableInfo": "Version {version} is ready to install", "pangolinUpdateAvailableReleaseNotes": "View release notes", diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index b0b3530a..6243cf88 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -5,9 +5,15 @@ import { useLocalStorage } from "@app/hooks/useLocalStorage"; import { cn } from "@app/lib/cn"; import { type ProductUpdate, productUpdatesQueries } from "@app/lib/queries"; import { useQueries } from "@tanstack/react-query"; -import { ArrowRight, BellIcon, ChevronRightIcon, XIcon } from "lucide-react"; +import { + ArrowRight, + BellIcon, + ChevronRightIcon, + RocketIcon, + XIcon +} from "lucide-react"; import { useTranslations } from "next-intl"; -import { Transition } from "@headlessui/react"; +import { Transition, TransitionChild } from "@headlessui/react"; import * as React from "react"; export default function ProductUpdates({ @@ -34,7 +40,7 @@ export default function ProductUpdates({ // we need to delay the initial React.useEffect(() => { - const timeout = setTimeout(() => setShowMoreUpdatesText(true), 500); + const timeout = setTimeout(() => setShowMoreUpdatesText(true), 600); return () => clearTimeout(timeout); }, []); @@ -42,62 +48,63 @@ export default function ProductUpdates({ string | null >("ignored-version", null); - const [showNewVersionPopup, setShowNewVersionPopup] = React.useState(true); - 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 showNewVersionPopup = Boolean( + data?.latestVersion?.data && + ignoredVersionUpdate !== + data.latestVersion.data?.pangolin.latestVersion && + env.app.version !== data.latestVersion.data?.pangolin.latestVersion + ); return (
+ <> +
+ + {data.updates.length > 0 && ( + <> + + + {showNewVersionPopup + ? t("productUpdateMoreInfo", { + noOfUpdates: data.updates.length + }) + : t("productUpdateInfo", { + noOfUpdates: data.updates.length + })} + + + )} + + 0} + /> +
+ + { - // setIgnoredVersionUpdate( - // data.latestVersion?.data?.pangolin.latestVersion ?? null - // ); - setShowNewVersionPopup(false); + setIgnoredVersionUpdate( + data.latestVersion?.data?.pangolin.latestVersion ?? null + ); }} show={showNewVersionPopup} /> - - - - {data.updates.length > 0 && ( - <> - - - {showNewVersionPopup - ? t("productUpdateMoreInfo", { - noOfUpdates: data.updates.length - }) - : t("productUpdateInfo", { - noOfUpdates: data.updates.length - })} - - - )} - - - 0} - />
); } @@ -119,6 +126,7 @@ function ProductUpdatesPopup({ updates, show }: ProductUpdatesPopupProps) {
-

What's new

+

{t("productUpdateWhatsNew")}

- +

From 41601010f4cc063c23fcc301f1926b1a740c4664 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 5 Nov 2025 23:58:56 +0100 Subject: [PATCH 14/70] =?UTF-8?q?=F0=9F=92=A1=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 6243cf88..2fe30c75 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -38,7 +38,7 @@ export default function ProductUpdates({ const t = useTranslations(); const [showMoreUpdatesText, setShowMoreUpdatesText] = React.useState(false); - // we need to delay the initial + // we delay the small text so that the user can notice it React.useEffect(() => { const timeout = setTimeout(() => setShowMoreUpdatesText(true), 600); return () => clearTimeout(timeout); From 096ca379cedf4a2ec4122ec45e5c2fde98f63ae6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 6 Nov 2025 00:06:05 +0100 Subject: [PATCH 15/70] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 64 +++++++++++++++---------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 2fe30c75..c8247ff8 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -13,7 +13,7 @@ import { XIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { Transition, TransitionChild } from "@headlessui/react"; +import { Transition } from "@headlessui/react"; import * as React from "react"; export default function ProductUpdates({ @@ -38,7 +38,7 @@ export default function ProductUpdates({ const t = useTranslations(); const [showMoreUpdatesText, setShowMoreUpdatesText] = React.useState(false); - // we delay the small text so that the user can notice it + // we delay the small text animation so that the user can notice it React.useEffect(() => { const timeout = setTimeout(() => setShowMoreUpdatesText(true), 600); return () => clearTimeout(timeout); @@ -64,37 +64,35 @@ export default function ProductUpdates({ isCollapsed && "hidden" )} > - <> -

- - {data.updates.length > 0 && ( - <> - - - {showNewVersionPopup - ? t("productUpdateMoreInfo", { - noOfUpdates: data.updates.length - }) - : t("productUpdateInfo", { - noOfUpdates: data.updates.length - })} - - - )} - - 0} - /> -
- +
+ + {data.updates.length > 0 && ( + <> + + + {showNewVersionPopup + ? t("productUpdateMoreInfo", { + noOfUpdates: data.updates.length + }) + : t("productUpdateInfo", { + noOfUpdates: data.updates.length + })} + + + )} + + 0} + /> +
Date: Thu, 6 Nov 2025 00:16:07 +0100 Subject: [PATCH 16/70] =?UTF-8?q?=F0=9F=9A=A7=20use=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 77 +++++++++++++++++-------------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index c8247ff8..b67d324a 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -15,6 +15,7 @@ import { import { useTranslations } from "next-intl"; import { Transition } from "@headlessui/react"; import * as React from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; export default function ProductUpdates({ isCollapsed @@ -88,7 +89,7 @@ export default function ProductUpdates({ )}
- 0} /> @@ -107,9 +108,12 @@ export default function ProductUpdates({ ); } -type ProductUpdatesPopupProps = { updates: ProductUpdate[]; show: boolean }; +type ProductUpdatesListPopupProps = { updates: ProductUpdate[]; show: boolean }; -function ProductUpdatesPopup({ updates, show }: ProductUpdatesPopupProps) { +function ProductUpdatesListPopup({ + updates, + show +}: ProductUpdatesListPopupProps) { const [open, setOpen] = React.useState(false); const t = useTranslations(); @@ -121,41 +125,44 @@ function ProductUpdatesPopup({ updates, show }: ProductUpdatesPopupProps) { }, [show]); return ( - -
-
- -
-
-

{t("productUpdateWhatsNew")}

- + + +
- -
-
+
+ +
+
+

+ {t("productUpdateWhatsNew")} +

+ + {updates[0].contents} + +
+
+ +
+ + + + Hello + + + ); } From 18757d7eb3df3d411e187840ea49c3c8fdca32d3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 6 Nov 2025 22:42:49 +0100 Subject: [PATCH 17/70] =?UTF-8?q?=F0=9F=92=84=20show=20product=20updates?= =?UTF-8?q?=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 ++ src/components/ProductUpdates.tsx | 39 +++++++++++++++++++++++-- src/lib/timeAgoFormatter.ts | 48 +++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/lib/timeAgoFormatter.ts diff --git a/messages/en-US.json b/messages/en-US.json index d5521aec..e731009d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1282,6 +1282,8 @@ "productUpdateMoreInfo": "{noOfUpdates} more updates", "productUpdateInfo": "{noOfUpdates} updates", "productUpdateWhatsNew": "What's New", + "productUpdateTitle": "Product Updates", + "dismissAll": "Dismiss all", "pangolinUpdateAvailable": "New version available", "pangolinUpdateAvailableInfo": "Version {version} is ready to install", "pangolinUpdateAvailableReleaseNotes": "View release notes", diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index b67d324a..5df3acd8 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -16,6 +16,9 @@ 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"; export default function ProductUpdates({ isCollapsed @@ -158,8 +161,40 @@ function ProductUpdatesListPopup({
- - Hello + +
+ + {t("productUpdateTitle")} + {updates.length} + + +
+
    + {updates.map((update) => ( +
  1. +

    + {update.title} + New +

    + + {update.contents} + + +
  2. + ))} +
diff --git a/src/lib/timeAgoFormatter.ts b/src/lib/timeAgoFormatter.ts new file mode 100644 index 00000000..0aeff8bc --- /dev/null +++ b/src/lib/timeAgoFormatter.ts @@ -0,0 +1,48 @@ +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("en", { + numeric: "auto", + style: short ? "narrow" : "long" + }); + const formatedValue = rtf.format(-value, unit); + return formatedValue === "now" ? "Just now" : formatedValue; +} From a62299c387e70b168ccd2427e838c5ebcaacd698 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 6 Nov 2025 23:25:53 +0100 Subject: [PATCH 18/70] =?UTF-8?q?=F0=9F=8E=A8=20prettier=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/badge.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 3bcf2bea..50ba04e0 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -10,7 +10,8 @@ const badgeVariants = cva( variant: { default: "border-transparent bg-primary text-primary-foreground", - outlinePrimary: "border-transparent bg-transparent border-primary text-primary", + outlinePrimary: + "border-transparent bg-transparent border-primary text-primary", secondary: "border-transparent bg-secondary text-secondary-foreground", destructive: @@ -18,12 +19,12 @@ const badgeVariants = cva( outline: "text-foreground", green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300", yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300", - red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300", - }, + red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300" + } }, defaultVariants: { - variant: "default", - }, + variant: "default" + } } ); From 45fb0a4156b6de624d201e237821d900f0bfe8cd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 6 Nov 2025 23:26:13 +0100 Subject: [PATCH 19/70] =?UTF-8?q?=F0=9F=92=84=20button=20for=20`mark=20as?= =?UTF-8?q?=20read`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 47 ++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 5df3acd8..c5f48d68 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -8,6 +8,7 @@ import { useQueries } from "@tanstack/react-query"; import { ArrowRight, BellIcon, + CheckIcon, ChevronRightIcon, RocketIcon, XIcon @@ -19,6 +20,12 @@ 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 @@ -164,25 +171,51 @@ function ProductUpdatesListPopup({
{t("productUpdateTitle")} {updates.length} - +
    {updates.map((update) => (
  1. -

    - {update.title} - New -

    +
    +

    + {update.title} + + New + +

    + + + + + + + Mark as read + + + +
    {update.contents} From f9287081565398f8281a62abfd825931fa6c54d2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Nov 2025 00:27:57 +0100 Subject: [PATCH 20/70] =?UTF-8?q?=F0=9F=92=84=20animate=20exit=20and=20mor?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 207 ++++++++++++++++++------------ src/lib/timeAgoFormatter.ts | 5 +- 2 files changed, 127 insertions(+), 85 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index c5f48d68..83fa32e6 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -8,7 +8,6 @@ import { useQueries } from "@tanstack/react-query"; import { ArrowRight, BellIcon, - CheckIcon, ChevronRightIcon, RocketIcon, XIcon @@ -57,7 +56,11 @@ export default function ProductUpdates({ const [ignoredVersionUpdate, setIgnoredVersionUpdate] = useLocalStorage< string | null - >("ignored-version", null); + >("product-updates:skip-version", null); + + const [productUpdatesRead, setProductUpdatesRead] = useLocalStorage< + number[] + >("product-updates:read", []); if (!data) return null; @@ -68,6 +71,10 @@ export default function ProductUpdates({ env.app.version !== data.latestVersion.data?.pangolin.latestVersion ); + const filteredUpdates = data.updates.filter( + (update) => !productUpdatesRead.includes(update.id) + ); + return (
    - {data.updates.length > 0 && ( + {filteredUpdates.length > 0 && ( <> {showNewVersionPopup ? t("productUpdateMoreInfo", { - noOfUpdates: data.updates.length + noOfUpdates: filteredUpdates.length }) : t("productUpdateInfo", { - noOfUpdates: data.updates.length + noOfUpdates: filteredUpdates.length })} )} 0} + updates={filteredUpdates} + show={filteredUpdates.length > 0} + onDimissAll={() => + setProductUpdatesRead([ + ...productUpdatesRead, + ...filteredUpdates.map((update) => update.id) + ]) + } + onDimiss={(id) => + setProductUpdatesRead([...productUpdatesRead, id]) + } />
    { + onDimiss={() => { setIgnoredVersionUpdate( data.latestVersion?.data?.pangolin.latestVersion ?? null ); @@ -118,29 +134,44 @@ export default function ProductUpdates({ ); } -type ProductUpdatesListPopupProps = { updates: ProductUpdate[]; show: boolean }; +type ProductUpdatesListPopupProps = { + updates: ProductUpdate[]; + show: boolean; + onDimiss: (id: number) => void; + onDimissAll: () => void; +}; function ProductUpdatesListPopup({ updates, - show + show, + onDimiss, + onDimissAll }: ProductUpdatesListPopupProps) { - const [open, setOpen] = React.useState(false); + 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(() => setOpen(true)); + requestAnimationFrame(() => setShowContent(true)); } }, [show]); + React.useEffect(() => { + if (updates.length === 0) { + setShowContent(false); + setPopoverOpen(false); + } + }, [updates.length]); + return ( - - + + -
- - - -
- - {t("productUpdateTitle")} - {updates.length} - -
-
    - {updates.map((update) => ( -
  1. -
    -

    - {update.title} - - New - -

    - - - - - - - Mark as read - - - -
    - - {update.contents} - - -
  2. - ))} -
-
+ + +
+ + {t("productUpdateTitle")} + {updates.length > 0 && ( + {updates.length} + )} + + +
+
    + {updates.length === 0 && ( + + No updates + + )} + {updates.map((update) => ( +
  1. +
    +

    + {update.title} + {/* + {t("new")} + */} +

    + + + + + + + {t("dismiss")} + + + +
    + + {update.contents} + + +
  2. + ))} +
+
); } type NewVersionAvailableProps = { - onClose: () => void; + onDimiss: () => void; show: boolean; version: | Awaited< @@ -251,7 +294,7 @@ type NewVersionAvailableProps = { function NewVersionAvailable({ version, show, - onClose + onDimiss }: NewVersionAvailableProps) { const t = useTranslations(); const [open, setOpen] = React.useState(false); @@ -302,7 +345,7 @@ function NewVersionAvailable({ className="p-1 cursor-pointer" onClick={() => { setOpen(false); - onClose(); + onDimiss(); }} > diff --git a/src/lib/timeAgoFormatter.ts b/src/lib/timeAgoFormatter.ts index 0aeff8bc..f6ae0175 100644 --- a/src/lib/timeAgoFormatter.ts +++ b/src/lib/timeAgoFormatter.ts @@ -39,10 +39,9 @@ export function timeAgoFormatter( unit = "year"; } - const rtf = new Intl.RelativeTimeFormat("en", { + const rtf = new Intl.RelativeTimeFormat(navigator.languages[0] ?? "en", { numeric: "auto", style: short ? "narrow" : "long" }); - const formatedValue = rtf.format(-value, unit); - return formatedValue === "now" ? "Just now" : formatedValue; + return rtf.format(-value, unit); } From 0b70cbb1a3ad0e34108ecdd73c882b3ad1cb3d72 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Nov 2025 01:10:20 +0100 Subject: [PATCH 21/70] =?UTF-8?q?=F0=9F=92=84=20show=20update=20type=20in?= =?UTF-8?q?=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 6 +++--- src/lib/queries.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 83fa32e6..094acd5e 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -231,12 +231,12 @@ function ProductUpdatesListPopup({

{update.title} - {/* - {t("new")} - */} + {update.type} +

diff --git a/src/lib/queries.ts b/src/lib/queries.ts index d8e4ee89..53aaeee7 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -8,7 +8,7 @@ export type ProductUpdate = { link: string | null; edition: "enterprise" | "community" | "cloud" | null; id: number; - priority: "CRITICAL" | "IMPORTANT" | "NORMAL" | null; + type: "Update" | "Important" | "New"; title: string; contents: string; publishedAt: Date; From ea744f8d284b4dadff65c54031f95af2145934c6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Nov 2025 01:14:05 +0100 Subject: [PATCH 22/70] =?UTF-8?q?=F0=9F=92=84=20show=20update=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 094acd5e..6d8a39f6 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -232,8 +232,15 @@ function ProductUpdatesListPopup({

{update.title} {update.type} From 9f9aa07c2d8a2bc37edfadef2f7ba38d43bff4c2 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Fri, 24 Oct 2025 23:36:20 +0530 Subject: [PATCH 23/70] Option to regenerate remote-nodes keys --- server/private/routers/external.ts | 8 + .../private/routers/remoteExitNode/index.ts | 1 + .../remoteExitNode/updateRemoteExitNode.ts | 106 +++++++++++ server/routers/remoteExitNode/types.ts | 5 + .../remote-exit-nodes/ExitNodesTable.tsx | 9 + .../[remoteExitNodeId]/general/page.tsx | 170 +++++++++++++++++- .../[remoteExitNodeId]/layout.tsx | 14 +- src/components/ExitNodeInfoCard.tsx | 52 ++++++ src/hooks/useRemoteExitNodeContext.ts | 6 +- 9 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 server/private/routers/remoteExitNode/updateRemoteExitNode.ts create mode 100644 src/components/ExitNodeInfoCard.tsx diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 00ad117f..8e2b2bbc 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -236,6 +236,14 @@ authenticated.put( remoteExitNode.createRemoteExitNode ); +authenticated.put( + "/org/:orgId/update-remote-exit-node", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + remoteExitNode.updateRemoteExitNode +); + authenticated.get( "/org/:orgId/remote-exit-nodes", verifyValidLicense, diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 2a04f9d9..a30e204c 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -21,3 +21,4 @@ export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; +export * from "./updateRemoteExitNode" diff --git a/server/private/routers/remoteExitNode/updateRemoteExitNode.ts b/server/private/routers/remoteExitNode/updateRemoteExitNode.ts new file mode 100644 index 00000000..9de017f8 --- /dev/null +++ b/server/private/routers/remoteExitNode/updateRemoteExitNode.ts @@ -0,0 +1,106 @@ +/* + * 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 { paramsSchema } from "./createRemoteExitNode"; + +const bodySchema = z + .object({ + remoteExitNodeId: z.string().length(15), + secret: z.string().length(48) + }) + .strict(); + +export async function updateRemoteExitNode( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { remoteExitNodeId, secret } = parsedBody.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const [existingRemoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + if (!existingRemoteExitNode) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Remote Exit Node does not exist") + ); + } + + const secretHash = await hashPassword(secret); + + await db + .update(remoteExitNodes) + .set({ secretHash }) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + + return response(res, { + data: { + remoteExitNodeId, + secret, + }, + success: true, + error: false, + message: "Remote Exit Node secret updated successfully", + status: HttpCode.OK, + }); + } catch (e) { + logger.error("Failed to update remoteExitNode", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update remoteExitNode" + ) + ); + } +} diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 55d0a286..ae0c2130 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -6,6 +6,11 @@ export type CreateRemoteExitNodeResponse = { secret: string; }; +export type UpdateRemoteExitNodeResponse = { + remoteExitNodeId: string; + secret: string; +} + export type PickRemoteExitNodeDefaultsResponse = { remoteExitNodeId: string; secret: string; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index 6e9ab237..06da3dc5 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -232,6 +232,7 @@ export default function ExitNodesTable({ id: "actions", cell: ({ row }) => { const nodeRow = row.original; + const remoteExitNodeId = nodeRow.id; return (
@@ -242,6 +243,14 @@ export default function ExitNodesTable({ + + + {t("viewSettings")} + + { setSelectedNode(nodeRow); diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx index 191ce3f3..6711177b 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx @@ -1,3 +1,171 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import CopyTextBox from "@app/components/CopyTextBox"; +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"; + export default function GeneralPage() { - return <>; + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext(); + + const [credentials, setCredentials] = + useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Clear credentials when user leaves/reloads + useEffect(() => { + const clearCreds = () => setCredentials(null); + window.addEventListener("beforeunload", clearCreds); + return () => window.removeEventListener("beforeunload", clearCreds); + }, []); + + const handleRegenerate = async () => { + try { + setLoading(true); + const response = await api.get< + AxiosResponse + >(`/org/${orgId}/pick-remote-exit-node-defaults`); + + setCredentials(response.data.data); + toast({ + title: t("success"), + description: t("Credentials generated successfully."), + }); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError( + error, + t("Failed to generate credentials") + ), + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!credentials) return; + + try { + setSaving(true); + + const response = await api.put< + AxiosResponse + >(`/org/${orgId}/update-remote-exit-node`, { + remoteExitNodeId: remoteExitNode.remoteExitNodeId, + secret: credentials.secret, + }); + + toast({ + title: t("success"), + description: t("Credentials saved successfully."), + }); + + // For security, clear them from UI + setCredentials(null); + + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError( + error, + t("Failed to save credentials") + ), + variant: "destructive", + }); + } finally { + setSaving(false); + } + }; + + return ( + + + + + {t("Generated Credentials")} + + + {t("Regenerate and save your managed credentials")} + + + + + {!credentials ? ( + + ) : ( + <> + + + + + + {t("Copy and save these credentials")} + + + {t( + "These credentials will not be shown again after you leave this page. Save them securely now." + )} + + + +
+ + +
+ + )} +
+
+
+ ); } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index 7a7b3611..6473999d 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -6,6 +6,8 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import RemoteExitNodeProvider from "@app/providers/RemoteExitNodeProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import ExitNodeInfoCard from "@app/components/ExitNodeInfoCard"; interface SettingsLayoutProps { children: React.ReactNode; @@ -31,6 +33,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); + const navItems = [ + { + title: t('general'), + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/general" + } + ]; + return ( <> -
{children}
+
+ + {children} +
); diff --git a/src/components/ExitNodeInfoCard.tsx b/src/components/ExitNodeInfoCard.tsx new file mode 100644 index 00000000..49ae1b61 --- /dev/null +++ b/src/components/ExitNodeInfoCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useTranslations } from "next-intl"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; + +type ExitNodeInfoCardProps = {}; + +export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) { + const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext(); + const t = useTranslations(); + + return ( + + + + <> + + {t("status")} + + {remoteExitNode.online ? ( +
+
+ {t("online")} +
+ ) : ( +
+
+ {t("offline")} +
+ )} +
+
+ + + {t("address")} + + {remoteExitNode.address} + + +
+
+
+ ); +} diff --git a/src/hooks/useRemoteExitNodeContext.ts b/src/hooks/useRemoteExitNodeContext.ts index 486147c4..6fe244c8 100644 --- a/src/hooks/useRemoteExitNodeContext.ts +++ b/src/hooks/useRemoteExitNodeContext.ts @@ -2,11 +2,15 @@ import RemoteExitNodeContext from "@app/contexts/remoteExitNodeContext"; import { build } from "@server/build"; +import { GetRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { useContext } from "react"; export function useRemoteExitNodeContext() { if (build == "oss") { - return null; + return { + remoteExitNode: {} as GetRemoteExitNodeResponse, + updateRemoteExitNode: () => {}, + }; } const context = useContext(RemoteExitNodeContext); if (context === undefined) { From 3f38080b46bdf56cb326936b1d9c215f45148dd2 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Fri, 24 Oct 2025 23:42:25 +0530 Subject: [PATCH 24/70] fix lint --- server/private/routers/remoteExitNode/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index a30e204c..5677520d 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -21,4 +21,4 @@ export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; -export * from "./updateRemoteExitNode" +export * from "./updateRemoteExitNode"; From c2f607bb9ad41adc62c5d7917c800478697fd543 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 25 Oct 2025 22:42:51 +0530 Subject: [PATCH 25/70] Option to regenerate olm keys inside client --- messages/en-US.json | 8 +- server/routers/client/updateClient.ts | 32 ++- .../[remoteExitNodeId]/general/page.tsx | 14 +- .../clients/[clientId]/credentials/page.tsx | 208 ++++++++++++++++++ .../settings/clients/[clientId]/layout.tsx | 4 + 5 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 063d9efc..9b2c35f8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2095,5 +2095,11 @@ "selectedResources": "Selected Resources", "enableSelected": "Enable Selected", "disableSelected": "Disable Selected", - "checkSelectedStatus": "Check Status of Selected" + "checkSelectedStatus": "Check Status of Selected", + "savecredentials": "Save Credentials", + "regeneratecredentials": "Regenerate Credentials", + "regenerateClientCredentials": "Regenerate and save your managed 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." } diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 884a9864..84e5f619 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { Client, db, exitNodes, sites } from "@server/db"; +import { Client, db, exitNodes, olms, sites } from "@server/db"; import { clients, clientSites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -18,6 +18,7 @@ import { deletePeer as olmDeletePeer } from "../olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; +import { hashPassword } from "@server/auth/password"; const updateClientParamsSchema = z .object({ @@ -30,7 +31,10 @@ const updateClientSchema = z name: z.string().min(1).max(255).optional(), siteIds: z .array(z.number().int().positive()) - .optional() + .optional(), + olmId: z.string().min(1).optional(), + secret: z.string().min(1).optional(), + }) .strict(); @@ -75,7 +79,7 @@ export async function updateClient( ); } - const { name, siteIds } = parsedBody.data; + const { name, siteIds, olmId, secret } = parsedBody.data; const parsedParams = updateClientParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -89,6 +93,12 @@ export async function updateClient( 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() @@ -136,6 +146,22 @@ export async function updateClient( .where(eq(clients.clientId, clientId)); } + const [existingOlm] = await trx + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + + if (existingOlm && olmId && secretHash) { + await trx + .update(olms) + .set({ + olmId, + secretHash + }) + .where(eq(olms.clientId, clientId)); + } + // Update site associations if provided // Remove sites that are no longer associated for (const siteId of sitesRemoved) { diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx index 6711177b..d207f0de 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx @@ -111,10 +111,10 @@ export default function GeneralPage() { - {t("Generated Credentials")} + {t("generatedcredentials")} - {t("Regenerate and save your managed credentials")} + {t("regenerateClientCredentials")} @@ -125,7 +125,7 @@ export default function GeneralPage() { loading={loading} disabled={loading} > - {t("Regenerate Credentials")} + {t("regeneratecredentials")} ) : ( <> @@ -138,11 +138,11 @@ export default function GeneralPage() { - {t("Copy and save these credentials")} + {t("copyandsavethesecredentials")} {t( - "These credentials will not be shown again after you leave this page. Save them securely now." + "copyandsavethesecredentialsdescription" )} @@ -152,14 +152,14 @@ export default function GeneralPage() { variant="outline" onClick={() => setCredentials(null)} > - {t("Cancel")} + {t("cancel")}
diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx new file mode 100644 index 00000000..fc86a93c --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +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 { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { PickClientDefaultsResponse } from "@server/routers/client"; +import { useClientContext } from "@app/hooks/useClientContext"; + +export default function GeneralPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const [olmId, setOlmId] = useState(""); + const [olmSecret, setOlmSecret] = useState(""); + const { client, updateClient } = useClientContext(); + + const [clientDefaults, setClientDefaults] = + useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Clear credentials when user leaves/reloads + useEffect(() => { + const clearCreds = () => { + setOlmId(""); + setOlmSecret(""); + }; + window.addEventListener("beforeunload", clearCreds); + return () => window.removeEventListener("beforeunload", clearCreds); + }, []); + + const handleRegenerate = async () => { + try { + setLoading(true); + await api + .get(`/org/${orgId}/pick-client-defaults`) + .then((res) => { + if (res && res.status === 200) { + const data = res.data.data; + + setClientDefaults(data); + + const olmId = data.olmId; + const olmSecret = data.olmSecret; + setOlmId(olmId); + setOlmSecret(olmSecret); + + } + }); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setLoading(true); + + try { + await api.post(`/client/${client?.clientId}`, { + olmId: clientDefaults?.olmId, + secret: clientDefaults?.olmSecret, + }); + + toast({ + title: t("clientUpdated"), + description: t("clientUpdatedDescription") + }); + + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("clientUpdateFailed"), + description: formatAxiosError( + e, + t("clientUpdateError") + ) + }); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {t("generatedcredentials")} + + + {t("regenerateClientCredentials")} + + + + + {!clientDefaults ? ( + + ) : ( + <> + + + + {t("clientOlmCredentials")} + + + {t("clientOlmCredentialsDescription")} + + + + + + + {t("olmEndpoint")} + + + + + + + + {t("olmId")} + + + + + + + + {t("olmSecretKey")} + + + + + + + + + + + {t("copyandsavethesecredentials")} + + + {t( + "copyandsavethesecredentialsdescription" + )} + + + + + +
+ + +
+ + )} +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx index c9c9fd14..e597f90d 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -34,6 +34,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { { title: "General", href: `/{orgId}/settings/clients/{clientId}/general` + }, + { + title: "Credentials", + href: `/{orgId}/settings/clients/{clientId}/credentials` } ]; From 42091e88cb19c31ce4d541500cc84433221a71e1 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 25 Oct 2025 23:25:18 +0530 Subject: [PATCH 26/70] rename exit node tab to credentials --- messages/en-US.json | 1 + .../[remoteExitNodeId]/{general => credentials}/page.tsx | 2 +- .../(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx | 4 ++-- .../(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx | 2 +- .../[orgId]/settings/clients/[clientId]/credentials/page.tsx | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) rename src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/{general => credentials}/page.tsx (99%) diff --git a/messages/en-US.json b/messages/en-US.json index 9b2c35f8..3f4083b1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2096,6 +2096,7 @@ "enableSelected": "Enable Selected", "disableSelected": "Disable Selected", "checkSelectedStatus": "Check Status of Selected", + "credentials": "Credentials", "savecredentials": "Save Credentials", "regeneratecredentials": "Regenerate Credentials", "regenerateClientCredentials": "Regenerate and save your managed credentials", diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx similarity index 99% rename from src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx rename to src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index d207f0de..38ccc334 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -25,7 +25,7 @@ import { } from "@server/routers/remoteExitNode/types"; import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; -export default function GeneralPage() { +export default function CredentialsPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index 6473999d..19357a7f 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -35,8 +35,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const navItems = [ { - title: t('general'), - href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/general" + title: t('credentials'), + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" } ]; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx index 6b39c1de..5b9fd628 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -5,6 +5,6 @@ export default async function RemoteExitNodePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/general` + `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials` ); } diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx index fc86a93c..1de1e696 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -22,7 +22,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard"; import { PickClientDefaultsResponse } from "@server/routers/client"; import { useClientContext } from "@app/hooks/useClientContext"; -export default function GeneralPage() { +export default function CredentialsPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); From d32505a833253dc937781bde5d2bc67f9750fff9 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 26 Oct 2025 21:00:46 +0530 Subject: [PATCH 27/70] Option to regenerate Newt keys --- messages/en-US.json | 8 +- server/auth/actions.ts | 1 + server/routers/client/index.ts | 3 +- .../routers/client/reGenerateClientSecret.ts | 130 +++++++++++ server/routers/client/updateClient.ts | 26 +-- server/routers/external.ts | 15 ++ server/routers/site/index.ts | 1 + server/routers/site/reGenerateSiteSecret.ts | 106 +++++++++ .../clients/[clientId]/credentials/page.tsx | 12 +- .../settings/clients/[clientId]/layout.tsx | 7 +- .../sites/[niceId]/credentials/page.tsx | 212 ++++++++++++++++++ .../settings/sites/[niceId]/layout.tsx | 4 + src/components/ClientInfoCard.tsx | 7 +- 13 files changed, 491 insertions(+), 41 deletions(-) create mode 100644 server/routers/client/reGenerateClientSecret.ts create mode 100644 server/routers/site/reGenerateSiteSecret.ts create mode 100644 src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 3f4083b1..a7d825f5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2099,8 +2099,12 @@ "credentials": "Credentials", "savecredentials": "Save Credentials", "regeneratecredentials": "Regenerate Credentials", - "regenerateClientCredentials": "Regenerate and save your managed credentials", + "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." + "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." } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index d08457e5..4608757b 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -19,6 +19,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + reGenerateSecret = "reGenerateSecret", createResource = "createResource", deleteResource = "deleteResource", getResource = "getResource", diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 385c7bed..9f97446e 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -3,4 +3,5 @@ export * from "./createClient"; export * from "./deleteClient"; export * from "./listClients"; export * from "./updateClient"; -export * from "./getClient"; \ No newline at end of file +export * from "./getClient"; +export * from "./reGenerateClientSecret"; \ No newline at end of file diff --git a/server/routers/client/reGenerateClientSecret.ts b/server/routers/client/reGenerateClientSecret.ts new file mode 100644 index 00000000..2bce396a --- /dev/null +++ b/server/routers/client/reGenerateClientSecret.ts @@ -0,0 +1,130 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, olms, } from "@server/db"; +import { clients } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { hashPassword } from "@server/auth/password"; + +const reGenerateSecretParamsSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const reGenerateSecretBodySchema = z + .object({ + olmId: z.string().min(1).optional(), + secret: z.string().min(1).optional(), + + }) + .strict(); + +export type ReGenerateSecretBody = z.infer; + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/regenerate-secret", + description: "Regenerate a client's OLM credentials by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: reGenerateSecretParamsSchema, + body: { + content: { + "application/json": { + schema: reGenerateSecretBodySchema + } + } + } + }, + responses: {} +}); + + +export async function reGenerateClientSecret( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = reGenerateSecretBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { olmId, secret } = parsedBody.data; + + const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + let secretHash = undefined; + if (secret) { + secretHash = await hashPassword(secret); + } + + + // Fetch the client to make sure it exists and the user has access to it + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + const [existingOlm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + + if (existingOlm && olmId && secretHash) { + await db + .update(olms) + .set({ + olmId, + secretHash + }) + .where(eq(olms.clientId, clientId)); + } + + return response(res, { + data: existingOlm, + success: true, + error: false, + message: "Credentials regenerated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 84e5f619..d458c4f8 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -32,9 +32,6 @@ const updateClientSchema = z siteIds: z .array(z.number().int().positive()) .optional(), - olmId: z.string().min(1).optional(), - secret: z.string().min(1).optional(), - }) .strict(); @@ -79,7 +76,7 @@ export async function updateClient( ); } - const { name, siteIds, olmId, secret } = parsedBody.data; + const { name, siteIds } = parsedBody.data; const parsedParams = updateClientParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -93,11 +90,6 @@ export async function updateClient( 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 @@ -146,22 +138,6 @@ export async function updateClient( .where(eq(clients.clientId, clientId)); } - const [existingOlm] = await trx - .select() - .from(olms) - .where(eq(olms.clientId, clientId)) - .limit(1); - - if (existingOlm && olmId && secretHash) { - await trx - .update(olms) - .set({ - olmId, - secretHash - }) - .where(eq(olms.clientId, clientId)); - } - // Update site associations if provided // Remove sites that are no longer associated for (const siteId of sitesRemoved) { diff --git a/server/routers/external.ts b/server/routers/external.ts index 5c235902..c2c518fa 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -178,6 +178,14 @@ authenticated.post( client.updateClient, ); +authenticated.post( + "/client/:clientId/regenerate-secret", + verifyClientsEnabled, + verifyClientAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + client.reGenerateClientSecret +); + // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, @@ -191,6 +199,13 @@ authenticated.post( logActionAudit(ActionsEnum.updateSite), site.updateSite, ); + +authenticated.post( + "/site/:siteId/regenerate-secret", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + site.reGenerateSiteSecret +); authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 3edf67c1..9b8b89cb 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -6,3 +6,4 @@ export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; export * from "./socketIntegration"; +export * from "./reGenerateSiteSecret"; \ No newline at end of file diff --git a/server/routers/site/reGenerateSiteSecret.ts b/server/routers/site/reGenerateSiteSecret.ts new file mode 100644 index 00000000..979212a4 --- /dev/null +++ b/server/routers/site/reGenerateSiteSecret.ts @@ -0,0 +1,106 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts } 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"; + +const updateSiteParamsSchema = z + .object({ + siteId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const updateSiteBodySchema = z + .object({ + newtId: z.string().min(1).max(255).optional(), + newtSecret: z.string().min(1).max(255).optional(), + }) + .strict() + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/regenerate-secret", + description: + "Regenerate a site's Newt credentials by its site ID.", + tags: [OpenAPITags.Site], + request: { + params: updateSiteParamsSchema, + body: { + content: { + "application/json": { + schema: updateSiteBodySchema + } + } + } + }, + responses: {} +}); + +export async function reGenerateSiteSecret( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateSiteBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + const { newtId, newtSecret } = parsedBody.data; + + const secretHash = await hashPassword(newtSecret!); + const updatedSite = await db + .update(newts) + .set({ + newtId, + secretHash + }) + .where(eq(newts.siteId, siteId)) + .returning(); + + if (updatedSite.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + return response(res, { + data: updatedSite[0], + success: true, + error: false, + message: "Credentials regenerated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx index 1de1e696..7d34b5eb 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -74,24 +74,24 @@ export default function CredentialsPage() { setLoading(true); try { - await api.post(`/client/${client?.clientId}`, { + await api.post(`/client/${client?.clientId}/regenerate-secret`, { olmId: clientDefaults?.olmId, secret: clientDefaults?.olmSecret, }); toast({ - title: t("clientUpdated"), - description: t("clientUpdatedDescription") + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") }); router.refresh(); } catch (e) { toast({ variant: "destructive", - title: t("clientUpdateFailed"), + title: t("credentialsSaveError"), description: formatAxiosError( e, - t("clientUpdateError") + t("credentialsSaveErrorDescription") ) }); } finally { @@ -107,7 +107,7 @@ export default function CredentialsPage() { {t("generatedcredentials")} - {t("regenerateClientCredentials")} + {t("regenerateCredentials")} diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx index e597f90d..dc4ef0b4 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -7,6 +7,7 @@ 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"; type SettingsLayoutProps = { children: React.ReactNode; @@ -30,13 +31,15 @@ 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` }, { - title: "Credentials", + title: t('credentials'), href: `/{orgId}/settings/clients/{clientId}/credentials` } ]; diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx new file mode 100644 index 00000000..14840b66 --- /dev/null +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +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 { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { PickSiteDefaultsResponse } from "@server/routers/site"; +import { useSiteContext } from "@app/hooks/useSiteContext"; + +export default function CredentialsPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + const [newtId, setNewtId] = useState(""); + const [newtSecret, setNewtSecret] = useState(""); + const { site, updateSite } = useSiteContext(); + + const [siteDefaults, setSiteDefaults] = + useState(null); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Clear credentials when user leaves/reloads + useEffect(() => { + const clearCreds = () => { + setNewtId(""); + setNewtSecret(""); + }; + window.addEventListener("beforeunload", clearCreds); + return () => window.removeEventListener("beforeunload", clearCreds); + }, []); + + const handleRegenerate = async () => { + try { + setLoading(true); + await api + .get(`/org/${orgId}/pick-site-defaults`) + .then((res) => { + if (res && res.status === 200) { + const data = res.data.data; + + setSiteDefaults(data); + + const newtId = data.newtId; + const newtSecret = data.newtSecret; + setNewtId(newtId); + setNewtSecret(newtSecret); + + } + }); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setLoading(true); + + try { + await api.post(`/site/${site?.siteId}/regenerate-secret`, { + newtId: siteDefaults?.newtId, + newtSecret: siteDefaults?.newtSecret, + }); + + toast({ + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") + }); + + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("credentialsSaveError"), + description: formatAxiosError( + e, + t("credentialsSaveErrorDescription") + ) + }); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {t("generatedcredentials")} + + + {t("regenerateCredentials")} + + + + + {!siteDefaults ? ( + + ) : ( + <> + + + + {t("siteNewtCredentials")} + + + {t( + "siteNewtCredentialsDescription" + )} + + + + + + + {t("newtEndpoint")} + + + + + + + + {t("newtId")} + + + + + + + + {t("newtSecretKey")} + + + + + + + + + + + + {t("copyandsavethesecredentials")} + + + {t( + "copyandsavethesecredentialsdescription" + )} + + + + + +
+ + +
+ + )} +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 039deebb..abd9aefb 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -36,6 +36,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { { title: t('general'), href: "/{orgId}/settings/sites/{niceId}/general" + }, + { + title: t('credentials'), + href: "/{orgId}/settings/sites/{niceId}/credentials" } ]; diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index ec8ecacf..f8d96158 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -1,7 +1,6 @@ "use client"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { InfoIcon } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { useClientContext } from "@app/hooks/useClientContext"; import { InfoSection, @@ -19,9 +18,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { return ( - - {t("clientInformation")} - + <> From 58a13de0ff2e7a29100f0578ba7ca014c0a39885 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 26 Oct 2025 21:09:00 +0530 Subject: [PATCH 28/70] fix lint --- server/routers/site/reGenerateSiteSecret.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/site/reGenerateSiteSecret.ts b/server/routers/site/reGenerateSiteSecret.ts index 979212a4..73d89a48 100644 --- a/server/routers/site/reGenerateSiteSecret.ts +++ b/server/routers/site/reGenerateSiteSecret.ts @@ -21,7 +21,7 @@ const updateSiteBodySchema = z newtId: z.string().min(1).max(255).optional(), newtSecret: z.string().min(1).max(255).optional(), }) - .strict() + .strict(); registry.registerPath({ method: "post", From 3756aaecdab99b19aa930ac107180704c0d2a619 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 26 Oct 2025 21:24:48 +0530 Subject: [PATCH 29/70] change file naming structure to reGenerate exit node keys --- server/private/routers/external.ts | 4 ++-- server/private/routers/remoteExitNode/index.ts | 2 +- .../{updateRemoteExitNode.ts => reGenerateExitNodeSecret.ts} | 2 +- .../remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename server/private/routers/remoteExitNode/{updateRemoteExitNode.ts => reGenerateExitNodeSecret.ts} (98%) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 8e2b2bbc..493e9646 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -237,11 +237,11 @@ authenticated.put( ); authenticated.put( - "/org/:orgId/update-remote-exit-node", + "/org/:orgId/reGenerate-remote-exit-node-secret", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateRemoteExitNode), - remoteExitNode.updateRemoteExitNode + remoteExitNode.reGenerateExitNodeSecret ); authenticated.get( diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 5677520d..7c001098 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -21,4 +21,4 @@ export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; -export * from "./updateRemoteExitNode"; +export * from "./reGenerateExitNodeSecret"; diff --git a/server/private/routers/remoteExitNode/updateRemoteExitNode.ts b/server/private/routers/remoteExitNode/reGenerateExitNodeSecret.ts similarity index 98% rename from server/private/routers/remoteExitNode/updateRemoteExitNode.ts rename to server/private/routers/remoteExitNode/reGenerateExitNodeSecret.ts index 9de017f8..b3785d2e 100644 --- a/server/private/routers/remoteExitNode/updateRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/reGenerateExitNodeSecret.ts @@ -32,7 +32,7 @@ const bodySchema = z }) .strict(); -export async function updateRemoteExitNode( +export async function reGenerateExitNodeSecret( req: Request, res: Response, next: NextFunction diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index 38ccc334..12a79dd6 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -79,7 +79,7 @@ export default function CredentialsPage() { const response = await api.put< AxiosResponse - >(`/org/${orgId}/update-remote-exit-node`, { + >(`/org/${orgId}/reGenerate-remote-exit-node-secret`, { remoteExitNodeId: remoteExitNode.remoteExitNodeId, secret: credentials.secret, }); From 563a5b3e7e594d2cbeee69d3e5ae0c61e2609b76 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 27 Oct 2025 19:16:11 +0530 Subject: [PATCH 30/70] disable credential regenerate button for local and wireguard --- src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index 14840b66..cb88ecba 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -117,7 +117,7 @@ export default function CredentialsPage() { From 18cdf070c72e5bbdcfb3a5ae2bbe7912f7de0b20 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 27 Oct 2025 19:34:43 +0530 Subject: [PATCH 31/70] add view setting options --- .../remote-exit-nodes/ExitNodesTable.tsx | 8 ++++++++ src/components/ClientsTable.tsx | 16 ++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index 06da3dc5..0834608d 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -263,6 +263,14 @@ export default function ExitNodesTable({ + + +

); } diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx index 471cdf28..e5f6a006 100644 --- a/src/components/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -278,14 +278,14 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} + + + View settings + + { setSelectedClient(clientRow); From f7e7993fd4dacd8a3e27a0aee88409670839059a Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Tue, 28 Oct 2025 19:26:58 +0530 Subject: [PATCH 32/70] regenerate secret for wireguard --- server/routers/site/reGenerateSiteSecret.ts | 123 ++++++--- .../sites/[niceId]/credentials/page.tsx | 251 +++++++++++++----- 2 files changed, 272 insertions(+), 102 deletions(-) diff --git a/server/routers/site/reGenerateSiteSecret.ts b/server/routers/site/reGenerateSiteSecret.ts index 73d89a48..7965b6f8 100644 --- a/server/routers/site/reGenerateSiteSecret.ts +++ b/server/routers/site/reGenerateSiteSecret.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts } from "@server/db"; +import { db, newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,8 @@ 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 "../gerbil/peers"; + const updateSiteParamsSchema = z .object({ @@ -18,28 +20,31 @@ const updateSiteParamsSchema = z 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: "/site/{siteId}/regenerate-secret", - description: - "Regenerate a site's Newt credentials by its site ID.", + 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 - } - } - } + schema: updateSiteBodySchema, + }, + }, + }, }, - responses: {} + responses: {}, }); export async function reGenerateSiteSecret( @@ -51,56 +56,100 @@ export async function reGenerateSiteSecret( const parsedParams = updateSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) + 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() - ) + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString()) ); } const { siteId } = parsedParams.data; - const { newtId, newtSecret } = parsedBody.data; + const { type, exitNodeId, pubKey, subnet, newtId, newtSecret } = parsedBody.data; - const secretHash = await hashPassword(newtSecret!); - const updatedSite = await db - .update(newts) - .set({ - newtId, - secretHash - }) - .where(eq(newts.siteId, siteId)) - .returning(); + let updatedSite = undefined; - if (updatedSite.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${siteId} not found` - ) - ); + 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[0], + data: updatedSite, success: true, error: false, message: "Credentials regenerated successfully", - status: HttpCode.OK + status: HttpCode.OK, }); + } catch (error) { - logger.error(error); + logger.error("Unexpected error in reGenerateSiteSecret", error); return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An unexpected error occurred") ); } } diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index cb88ecba..0526149d 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -21,6 +21,9 @@ import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from import CopyToClipboard from "@app/components/CopyToClipboard"; import { PickSiteDefaultsResponse } from "@server/routers/site"; import { useSiteContext } from "@app/hooks/useSiteContext"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { QRCodeCanvas } from "qrcode.react"; +import { generateKeypair } from "../wireguardConfig"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -31,12 +34,36 @@ export default function CredentialsPage() { const [newtId, setNewtId] = useState(""); const [newtSecret, setNewtSecret] = useState(""); const { site, updateSite } = useSiteContext(); - + const [wgConfig, setWgConfig] = useState(""); const [siteDefaults, setSiteDefaults] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [publicKey, setPublicKey] = useState(""); + const [privateKey, setPrivateKey] = useState(""); + + const hydrateWireGuardConfig = ( + privateKey: string, + publicKey: string, + subnet: string, + address: string, + endpoint: string, + listenPort: string + ) => { + const wgConfig = `[Interface] +Address = ${subnet} +ListenPort = 51820 +PrivateKey = ${privateKey} + +[Peer] +PublicKey = ${publicKey} +AllowedIPs = ${address.split("/")[0]}/32 +Endpoint = ${endpoint}:${listenPort} +PersistentKeepalive = 5`; + setWgConfig(wgConfig); + }; + // Clear credentials when user leaves/reloads useEffect(() => { @@ -49,6 +76,14 @@ export default function CredentialsPage() { }, []); const handleRegenerate = async () => { + + const generatedKeypair = generateKeypair(); + + const privateKey = generatedKeypair.privateKey; + const publicKey = generatedKeypair.publicKey; + + setPrivateKey(privateKey); + setPublicKey(publicKey); try { setLoading(true); await api @@ -64,6 +99,15 @@ export default function CredentialsPage() { setNewtId(newtId); setNewtSecret(newtSecret); + hydrateWireGuardConfig( + privateKey, + data.publicKey, + data.subnet, + data.address, + data.endpoint, + data.listenPort + ); + } }); } finally { @@ -74,11 +118,46 @@ export default function CredentialsPage() { const handleSave = async () => { setLoading(true); - try { - await api.post(`/site/${site?.siteId}/regenerate-secret`, { + let payload: any = {}; + + if (site?.type === "wireguard") { + if (!siteDefaults || !wgConfig) { + toast({ + variant: "destructive", + title: t("siteErrorCreate"), + description: t("siteErrorCreateKeyPair") + }); + setLoading(false); + return; + } + + payload = { + type: "wireguard", + subnet: siteDefaults.subnet, + exitNodeId: siteDefaults.exitNodeId, + pubKey: publicKey + }; + } + if (site?.type === "newt") { + if (!siteDefaults) { + toast({ + variant: "destructive", + title: t("siteErrorCreate"), + description: t("siteErrorCreateDefaults") + }); + setLoading(false); + return; + } + + payload = { + type: "newt", newtId: siteDefaults?.newtId, - newtSecret: siteDefaults?.newtSecret, - }); + newtSecret: siteDefaults?.newtSecret + }; + } + + try { + await api.post(`/site/${site?.siteId}/regenerate-secret`, payload); toast({ title: t("credentialsSaved"), @@ -100,6 +179,7 @@ export default function CredentialsPage() { } }; + return ( @@ -117,73 +197,114 @@ export default function CredentialsPage() { ) : ( <> - - - - {t("siteNewtCredentials")} - - - {t( - "siteNewtCredentialsDescription" - )} - - - - - - - {t("newtEndpoint")} - - - - - - - - {t("newtId")} - - - - - - - - {t("newtSecretKey")} - - - - - - - - - - - - {t("copyandsavethesecredentials")} - - + {site.type === "wireguard" && ( + + + + {t("WgConfiguration")} + + + {t("WgConfigurationDescription")} + + + +
+ +
+
+ +
+
+
+ + + + {t("siteCredentialsSave")} + + + {t( + "siteCredentialsSaveDescription" + )} + + +
+
+ )} + {site.type === "newt" && ( + + + + {t("siteNewtCredentials")} + + {t( - "copyandsavethesecredentialsdescription" + "siteNewtCredentialsDescription" )} -
-
-
-
+ + + + + + + {t("newtEndpoint")} + + + + + + + + {t("newtId")} + + + + + + + + {t("newtSecretKey")} + + + + + + + + + + + + {t("copyandsavethesecredentials")} + + + {t( + "copyandsavethesecredentialsdescription" + )} + + + +
+ )}
- + - {t("siteCredentialsSave")} + {t("copyandsavethesecredentials")} {t( - "siteCredentialsSaveDescription" + "copyandsavethesecredentialsdescription" )} From 90e72c6aca7098551b3bac7c5b61a3fc01fcf8b3 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Fri, 7 Nov 2025 00:59:03 +0530 Subject: [PATCH 34/70] hide credentials tab for local sites --- messages/en-US.json | 2 +- .../[orgId]/settings/sites/[niceId]/layout.tsx | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a7d825f5..7928b69b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2098,7 +2098,7 @@ "checkSelectedStatus": "Check Status of Selected", "credentials": "Credentials", "savecredentials": "Save Credentials", - "regeneratecredentials": "Regenerate Credentials", + "regeneratecredentials": "Re-key", "regenerateCredentials": "Regenerate and save your credentials", "generatedcredentials": "Generated Credentials", "copyandsavethesecredentials": "Copy and save these credentials", diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index abd9aefb..01008dab 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -35,18 +35,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`, }, - { - title: t('credentials'), - href: "/{orgId}/settings/sites/{niceId}/credentials" - } + ...(site.type !== 'local' + ? [ + { + title: t('credentials'), + href: `/${params.orgId}/settings/sites/${params.niceId}/credentials`, + }, + ] + : []), ]; + return ( <> From 2b8204fdc8b916546497b96f378d00b6173a69eb Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Fri, 7 Nov 2025 23:30:24 +0530 Subject: [PATCH 35/70] seperate credentials rekeying in modal for reuse --- messages/en-US.json | 8 +- .../[remoteExitNodeId]/credentials/page.tsx | 164 +++------ .../clients/[clientId]/credentials/page.tsx | 189 ++-------- .../sites/[niceId]/credentials/page.tsx | 337 +++++------------- src/components/RegenerateCredentialsModal.tsx | 216 +++++++++++ 5 files changed, 391 insertions(+), 523 deletions(-) create mode 100644 src/components/RegenerateCredentialsModal.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7928b69b..dc4b429a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2106,5 +2106,11 @@ "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." + "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" } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index 12a79dd6..b605ad35 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { SettingsContainer, SettingsSection, @@ -10,9 +10,6 @@ import { SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; -import CopyTextBox from "@app/components/CopyTextBox"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -24,6 +21,7 @@ import { QuickStartRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; +import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -31,79 +29,44 @@ export default function CredentialsPage() { const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); - const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext(); + const { remoteExitNode } = useRemoteExitNodeContext(); - const [credentials, setCredentials] = - useState(null); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [credentials, setCredentials] = useState(null); - // Clear credentials when user leaves/reloads - useEffect(() => { - const clearCreds = () => setCredentials(null); - window.addEventListener("beforeunload", clearCreds); - return () => window.removeEventListener("beforeunload", clearCreds); - }, []); + const handleConfirmRegenerate = async () => { + + const response = await api.get>( + `/org/${orgId}/pick-remote-exit-node-defaults` + ); - const handleRegenerate = async () => { - try { - setLoading(true); - const response = await api.get< - AxiosResponse - >(`/org/${orgId}/pick-remote-exit-node-defaults`); + const data = response.data.data; + setCredentials(data); - setCredentials(response.data.data); - toast({ - title: t("success"), - description: t("Credentials generated successfully."), - }); - } catch (error) { - toast({ - title: t("error"), - description: formatAxiosError( - error, - t("Failed to generate credentials") - ), - variant: "destructive", - }); - } finally { - setLoading(false); - } + await api.put>( + `/org/${orgId}/reGenerate-remote-exit-node-secret`, + { + remoteExitNodeId: remoteExitNode.remoteExitNodeId, + secret: data.secret, + } + ); + + toast({ + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") + }); + + router.refresh(); }; - const handleSave = async () => { - if (!credentials) return; - - try { - setSaving(true); - - const response = await api.put< - AxiosResponse - >(`/org/${orgId}/reGenerate-remote-exit-node-secret`, { - remoteExitNodeId: remoteExitNode.remoteExitNodeId, - secret: credentials.secret, - }); - - toast({ - title: t("success"), - description: t("Credentials saved successfully."), - }); - - // For security, clear them from UI - setCredentials(null); - - } catch (error) { - toast({ - title: t("error"), - description: formatAxiosError( - error, - t("Failed to save credentials") - ), - variant: "destructive", - }); - } finally { - setSaving(false); + const getCredentials = () => { + if (credentials) { + return { + Id: remoteExitNode.remoteExitNodeId, + Secret: credentials.secret + }; } + return undefined; }; return ( @@ -114,58 +77,25 @@ export default function CredentialsPage() { {t("generatedcredentials")} - {t("regenerateClientCredentials")} + {t("regenerateCredentials")} - {!credentials ? ( - - ) : ( - <> - - - - - - {t("copyandsavethesecredentials")} - - - {t( - "copyandsavethesecredentialsdescription" - )} - - - -
- - -
- - )} +
+ + ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx index 7d34b5eb..e4a67544 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { SettingsContainer, SettingsSection, @@ -10,17 +10,14 @@ import { SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; 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 { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import CopyToClipboard from "@app/components/CopyToClipboard"; import { PickClientDefaultsResponse } from "@server/routers/client"; import { useClientContext } from "@app/hooks/useClientContext"; +import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -28,55 +25,21 @@ export default function CredentialsPage() { const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); - const [olmId, setOlmId] = useState(""); - const [olmSecret, setOlmSecret] = useState(""); - const { client, updateClient } = useClientContext(); + const { client } = useClientContext(); + + const [modalOpen, setModalOpen] = useState(false); + const [clientDefaults, setClientDefaults] = useState(null); - const [clientDefaults, setClientDefaults] = - useState(null); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); + 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); - // Clear credentials when user leaves/reloads - useEffect(() => { - const clearCreds = () => { - setOlmId(""); - setOlmSecret(""); - }; - window.addEventListener("beforeunload", clearCreds); - return () => window.removeEventListener("beforeunload", clearCreds); - }, []); - - const handleRegenerate = async () => { - try { - setLoading(true); - await api - .get(`/org/${orgId}/pick-client-defaults`) - .then((res) => { - if (res && res.status === 200) { - const data = res.data.data; - - setClientDefaults(data); - - const olmId = data.olmId; - const olmSecret = data.olmSecret; - setOlmId(olmId); - setOlmSecret(olmSecret); - - } - }); - } finally { - setLoading(false); - } - }; - - const handleSave = async () => { - setLoading(true); - - try { await api.post(`/client/${client?.clientId}/regenerate-secret`, { - olmId: clientDefaults?.olmId, - secret: clientDefaults?.olmSecret, + olmId: data.olmId, + secret: data.olmSecret, }); toast({ @@ -85,20 +48,19 @@ export default function CredentialsPage() { }); router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("credentialsSaveError"), - description: formatAxiosError( - e, - t("credentialsSaveErrorDescription") - ) - }); - } finally { - setLoading(false); } }; + const getCredentials = () => { + if (clientDefaults) { + return { + Id: clientDefaults.olmId, + Secret: clientDefaults.olmSecret + }; + } + return undefined; + }; + return ( @@ -112,97 +74,20 @@ export default function CredentialsPage() { - {!clientDefaults ? ( - - ) : ( - <> - - - - {t("clientOlmCredentials")} - - - {t("clientOlmCredentialsDescription")} - - - - - - - {t("olmEndpoint")} - - - - - - - - {t("olmId")} - - - - - - - - {t("olmSecretKey")} - - - - - - - - - - - {t("copyandsavethesecredentials")} - - - {t( - "copyandsavethesecredentialsdescription" - )} - - - - - -
- - -
- - )} +
+ +
); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index 1420680c..3942ef83 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { SettingsContainer, SettingsSection, @@ -10,20 +10,15 @@ import { SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; 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 { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import CopyToClipboard from "@app/components/CopyToClipboard"; import { PickSiteDefaultsResponse } from "@server/routers/site"; import { useSiteContext } from "@app/hooks/useSiteContext"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { QRCodeCanvas } from "qrcode.react"; import { generateKeypair } from "../wireguardConfig"; +import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -31,17 +26,12 @@ export default function CredentialsPage() { const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); - const [newtId, setNewtId] = useState(""); - const [newtSecret, setNewtSecret] = useState(""); - const { site, updateSite } = useSiteContext(); + const { site } = useSiteContext(); + + const [modalOpen, setModalOpen] = useState(false); + const [siteDefaults, setSiteDefaults] = useState(null); const [wgConfig, setWgConfig] = useState(""); - const [siteDefaults, setSiteDefaults] = - useState(null); - - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); const [publicKey, setPublicKey] = useState(""); - const [privateKey, setPrivateKey] = useState(""); const hydrateWireGuardConfig = ( privateKey: string, @@ -51,7 +41,7 @@ export default function CredentialsPage() { endpoint: string, listenPort: string ) => { - const wgConfig = `[Interface] + const config = `[Interface] Address = ${subnet} ListenPort = 51820 PrivateKey = ${privateKey} @@ -61,124 +51,83 @@ PublicKey = ${publicKey} AllowedIPs = ${address.split("/")[0]}/32 Endpoint = ${endpoint}:${listenPort} PersistentKeepalive = 5`; - setWgConfig(wgConfig); + setWgConfig(config); + return config; }; - - // Clear credentials when user leaves/reloads - useEffect(() => { - const clearCreds = () => { - setNewtId(""); - setNewtSecret(""); - }; - window.addEventListener("beforeunload", clearCreds); - return () => window.removeEventListener("beforeunload", clearCreds); - }, []); - - const handleRegenerate = async () => { - - const generatedKeypair = generateKeypair(); - - const privateKey = generatedKeypair.privateKey; - const publicKey = generatedKeypair.publicKey; - - setPrivateKey(privateKey); - setPublicKey(publicKey); - try { - setLoading(true); - await api - .get(`/org/${orgId}/pick-site-defaults`) - .then((res) => { - if (res && res.status === 200) { - const data = res.data.data; - - setSiteDefaults(data); - - const newtId = data.newtId; - const newtSecret = data.newtSecret; - setNewtId(newtId); - setNewtSecret(newtSecret); - - hydrateWireGuardConfig( - privateKey, - data.publicKey, - data.subnet, - data.address, - data.endpoint, - data.listenPort - ); - - } - }); - } finally { - setLoading(false); - } - }; - - const handleSave = async () => { - setLoading(true); - - let payload: any = {}; + const handleConfirmRegenerate = async () => { + let generatedPublicKey = ""; + let generatedWgConfig = ""; if (site?.type === "wireguard") { - if (!siteDefaults || !wgConfig) { - toast({ - variant: "destructive", - title: t("siteErrorCreate"), - description: t("siteErrorCreateKeyPair") - }); - setLoading(false); - return; + 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 + ); } - payload = { + await api.post(`/site/${site?.siteId}/regenerate-secret`, { type: "wireguard", - subnet: siteDefaults.subnet, - exitNodeId: siteDefaults.exitNodeId, - pubKey: publicKey - }; + subnet: res.data.data.subnet, + exitNodeId: res.data.data.exitNodeId, + pubKey: generatedPublicKey + }); } + if (site?.type === "newt") { - if (!siteDefaults) { - toast({ - variant: "destructive", - title: t("siteErrorCreate"), - description: t("siteErrorCreateDefaults") + 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(`/site/${site?.siteId}/regenerate-secret`, { + type: "newt", + newtId: data.newtId, + newtSecret: data.newtSecret }); - setLoading(false); - return; } - - payload = { - type: "newt", - newtId: siteDefaults?.newtId, - newtSecret: siteDefaults?.newtSecret - }; } - try { - await api.post(`/site/${site?.siteId}/regenerate-secret`, payload); + toast({ + title: t("credentialsSaved"), + description: t("credentialsSavedDescription") + }); - toast({ - title: t("credentialsSaved"), - description: t("credentialsSavedDescription") - }); - - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("credentialsSaveError"), - description: formatAxiosError( - e, - t("credentialsSaveErrorDescription") - ) - }); - } finally { - setLoading(false); - } + 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 ( @@ -193,141 +142,23 @@ PersistentKeepalive = 5`; - {!siteDefaults ? ( - - ) : ( - <> - {site.type === "wireguard" && ( - - - - {t("WgConfiguration")} - - - {t("WgConfigurationDescription")} - - - -
- -
-
- -
-
-
- - - - {t("copyandsavethesecredentials")} - - - {t( - "copyandsavethesecredentialsdescription" - )} - - -
-
- )} - {site.type === "newt" && ( - - - - {t("siteNewtCredentials")} - - - {t( - "siteNewtCredentialsDescription" - )} - - - - - - - {t("newtEndpoint")} - - - - - - - - {t("newtId")} - - - - - - - - {t("newtSecretKey")} - - - - - - - - - - - - {t("copyandsavethesecredentials")} - - - {t( - "copyandsavethesecredentialsdescription" - )} - - - - - )} - -
- - -
- - )} +
+ +
); -} +} \ No newline at end of file diff --git a/src/components/RegenerateCredentialsModal.tsx b/src/components/RegenerateCredentialsModal.tsx new file mode 100644 index 00000000..f485746b --- /dev/null +++ b/src/components/RegenerateCredentialsModal.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, AlertTriangle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { QRCodeCanvas } from "qrcode.react"; + +type CredentialType = "site-wireguard" | "site-newt" | "client-olm" | "remote-exit-node"; + +interface RegenerateCredentialsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: CredentialType; + onConfirmRegenerate: () => Promise; + dashboardUrl: string; + credentials?: { + // For WireGuard sites + wgConfig?: string; + + Id?: string; + Secret?: string; + }; +} + +export default function RegenerateCredentialsModal({ + open, + onOpenChange, + type, + onConfirmRegenerate, + dashboardUrl, + credentials +}: RegenerateCredentialsModalProps) { + const t = useTranslations(); + const [stage, setStage] = useState<"confirm" | "show">("confirm"); + const [loading, setLoading] = useState(false); + + const handleConfirm = async () => { + try { + setLoading(true); + await onConfirmRegenerate(); + setStage("show"); + } catch (error) { + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setStage("confirm"); + onOpenChange(false); + }; + + const getTitle = () => { + if (stage === "confirm") { + return t("regeneratecredentials"); + } + switch (type) { + case "site-wireguard": + return t("WgConfiguration"); + case "site-newt": + return t("siteNewtCredentials"); + case "client-olm": + return t("clientOlmCredentials"); + case "remote-exit-node": + return t("remoteExitNodeCreate.generate.title"); + } + }; + + const getDescription = () => { + if (stage === "confirm") { + return t("regenerateCredentialsWarning"); + } + switch (type) { + case "site-wireguard": + return t("WgConfigurationDescription"); + case "site-newt": + return t("siteNewtCredentialsDescription"); + case "client-olm": + return t("clientOlmCredentialsDescription"); + case "remote-exit-node": + return t("remoteExitNodeCreate.generate.description"); + } + }; + + return ( + + + + {getTitle()} + {getDescription()} + + + + {stage === "confirm" ? ( + + + + {t("warning")} + + + {t("regenerateCredentialsConfirmation")} + + + ) : ( + <> + {credentials?.wgConfig && ( +
+
+ +
+
+ +
+
+
+ + + + + {t("copyandsavethesecredentials")} + + + {t("copyandsavethesecredentialsdescription")} + + +
+ )} + + {credentials?.Id && credentials.Secret && ( +
+ + + + {t("endpoint")} + + + + + + + + {t("Id")} + + + + + + + + {t("SecretKey")} + + + + + + + + + + {t("copyandsavethesecredentials")} + + + {t("copyandsavethesecredentialsdescription")} + + +
+ + )} + + )} +
+ + + {stage === "confirm" ? ( + <> + + + + + + ) : ( + + )} + +
+
+ ); +} \ No newline at end of file From aa3f07f1bae235ca93682bdf421423e1345835bd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Nov 2025 20:05:29 +0100 Subject: [PATCH 36/70] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20make=20fossorial=20r?= =?UTF-8?q?emote=20API=20only=20configurable=20on=20the=20frontend=20and?= =?UTF-8?q?=20only=20in=20`DEV`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/config.ts | 4 +--- .../routers/generatedLicense/generateNewLicense.ts | 4 +--- .../routers/generatedLicense/listGeneratedLicenses.ts | 4 +--- server/routers/supporterKey/validateSupporterKey.ts | 6 +----- src/lib/api/index.ts | 8 +++++++- src/lib/pullEnv.ts | 8 +------- src/lib/types/env.ts | 1 - 7 files changed, 12 insertions(+), 23 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index f71cfd51..13a1f6a4 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -6,7 +6,6 @@ import { eq } from "drizzle-orm"; import { configSchema, readConfigFile } from "./readConfigFile"; import { fromError } from "zod-validation-error"; import { build } from "@server/build"; -import { pullEnv } from "@app/lib/pullEnv"; export class Config { private rawConfig!: z.infer; @@ -150,7 +149,6 @@ export class Config { public async checkSupporterKey() { const [key] = await db.select().from(supporterKey).limit(1); - const env = pullEnv(); if (!key) { return; @@ -160,7 +158,7 @@ export class Config { try { const response = await fetch( - `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license/validate`, + `https://api.fossorial.io/api/v1/license/validate`, { method: "POST", headers: { diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index dfbaaa89..2c0c4420 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -18,13 +18,11 @@ import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; -import { pullEnv } from "@app/lib/pullEnv"; async function createNewLicense(orgId: string, licenseData: any): Promise { try { - const env = pullEnv(); const response = await fetch( - `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license-internal/enterprise/${orgId}/create`, + `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`, { method: "PUT", headers: { diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index 839c8a2c..fb54c763 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -21,13 +21,11 @@ import { GeneratedLicenseKey, ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; -import { pullEnv } from "@app/lib/pullEnv"; async function fetchLicenseKeys(orgId: string): Promise { try { - const env = pullEnv(); const response = await fetch( - `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license-internal/enterprise/${orgId}/list`, + `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`, { method: "GET", headers: { diff --git a/server/routers/supporterKey/validateSupporterKey.ts b/server/routers/supporterKey/validateSupporterKey.ts index 82315017..338c920e 100644 --- a/server/routers/supporterKey/validateSupporterKey.ts +++ b/server/routers/supporterKey/validateSupporterKey.ts @@ -5,12 +5,9 @@ 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"; -import { pullEnv } from "@app/lib/pullEnv"; const validateSupporterKeySchema = z .object({ @@ -32,7 +29,6 @@ export async function validateSupporterKey( next: NextFunction ): Promise { try { - const env = pullEnv(); const parsedBody = validateSupporterKeySchema.safeParse(req.body); if (!parsedBody.success) { return next( @@ -46,7 +42,7 @@ export async function validateSupporterKey( const { githubUsername, key } = parsedBody.data; const response = await fetch( - `${env.app.fossorialRemoteAPIBaseUrl}/api/v1/license/validate`, + `https://api.fossorial.io/api/v1/license/validate`, { method: "POST", headers: { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index a75d3abe..14735053 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -51,8 +51,14 @@ 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: `${process.env.NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL}/api/v1`, + baseURL: `${remoteAPIURL}/api/v1`, timeout: 10000, headers: { "Content-Type": "application/json", diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 7e5a0dc4..7893a8c6 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -21,11 +21,6 @@ const envSchema = z.object({ .transform((val) => val === "true"), APP_VERSION: z.string(), DASHBOARD_URL: z.string(), - NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL: z - .string() - .url() - .default("https://api.fossorial.io") - .transform((url) => url.replace(/(.*)\/?$/, "$1")), // Email configuration EMAIL_ENABLED: z @@ -117,8 +112,7 @@ export function pullEnv(): Env { environment: env.ENVIRONMENT, sandbox_mode: env.SANDBOX_MODE, version: env.APP_VERSION, - dashboardUrl: env.DASHBOARD_URL, - fossorialRemoteAPIBaseUrl: env.NEXT_PUBLIC_FOSSORIAL_REMOTE_API_URL + dashboardUrl: env.DASHBOARD_URL }, email: { emailEnabled: env.EMAIL_ENABLED diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 64bc8d73..6ebf758a 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -4,7 +4,6 @@ export type Env = { sandbox_mode: boolean; version: string; dashboardUrl: string; - fossorialRemoteAPIBaseUrl: string; }; server: { externalPort: string; From 0745734273a5d348386b6ed7a9534c1223165537 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Nov 2025 20:05:51 +0100 Subject: [PATCH 37/70] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20include=20`build`=20?= =?UTF-8?q?when=20getting=20product=20udpates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 53aaeee7..7ea4b07b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -6,9 +6,9 @@ import type ResponseT from "@server/types/Response"; export type ProductUpdate = { link: string | null; - edition: "enterprise" | "community" | "cloud" | null; + build: "enterprise" | "oss" | "saas" | null; id: number; - type: "Update" | "Important" | "New"; + type: "Update" | "Important" | "New" | "Warning"; title: string; contents: string; publishedAt: Date; @@ -19,8 +19,11 @@ export const productUpdatesQueries = { list: queryOptions({ queryKey: ["PRODUCT_UPDATES"] as const, queryFn: async ({ signal }) => { + const sp = new URLSearchParams({ + build + }); const data = await remote.get>( - "/product-updates", + `/product-updates?${sp.toString()}`, { signal } ); return data.data; From 8a5f59cb9ffc014437777ce6ce5273050c8839fb Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 8 Nov 2025 01:38:47 +0530 Subject: [PATCH 38/70] disable re-key button for non licensed --- messages/en-US.json | 3 +- .../[remoteExitNodeId]/credentials/page.tsx | 40 +++++++++++++++-- .../clients/[clientId]/credentials/page.tsx | 41 ++++++++++++++--- .../sites/[niceId]/credentials/page.tsx | 45 +++++++++++++++---- 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index dc4b429a..c9d55062 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2112,5 +2112,6 @@ "regenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials?", "endpoint": "Endpoint", "id": "Id", - "SecretKey": "Secret Key" + "SecretKey": "Secret Key", + "featureDisabledTooltip": "This feature is only available in the enterprise plan and require a license to use it." } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index b605ad35..0fcdcbbb 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -22,6 +22,10 @@ import { } 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(); @@ -34,8 +38,19 @@ export default function CredentialsPage() { const [modalOpen, setModalOpen] = useState(false); const [credentials, setCredentials] = useState(null); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscription?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; + + const handleConfirmRegenerate = async () => { - + const response = await api.get>( `/org/${orgId}/pick-remote-exit-node-defaults` ); @@ -82,9 +97,26 @@ export default function CredentialsPage() { - + + + +
+ +
+
+ + {isSecurityFeatureDisabled() && ( + + {t("featureDisabledTooltip")} + + )} +
+
diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx index e4a67544..024c539a 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -18,6 +18,10 @@ 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(); @@ -26,12 +30,23 @@ export default function CredentialsPage() { const router = useRouter(); const t = useTranslations(); const { client } = useClientContext(); - + const [modalOpen, setModalOpen] = useState(false); const [clientDefaults, setClientDefaults] = useState(null); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscription?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; + + const handleConfirmRegenerate = async () => { - + const res = await api.get(`/org/${orgId}/pick-client-defaults`); if (res && res.status === 200) { const data = res.data.data; @@ -74,9 +89,25 @@ export default function CredentialsPage() { - + + + +
+ +
+
+ + {isSecurityFeatureDisabled() && ( + + {t("featureDisabledTooltip")} + + )} +
+
diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index 3942ef83..8351c730 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -19,6 +19,10 @@ 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(); @@ -27,12 +31,23 @@ export default function CredentialsPage() { const router = useRouter(); const t = useTranslations(); const { site } = useSiteContext(); - + const [modalOpen, setModalOpen] = useState(false); const [siteDefaults, setSiteDefaults] = useState(null); const [wgConfig, setWgConfig] = useState(""); const [publicKey, setPublicKey] = useState(""); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscription?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; + + const hydrateWireGuardConfig = ( privateKey: string, publicKey: string, @@ -113,7 +128,7 @@ PersistentKeepalive = 5`; const getCredentialType = () => { if (site?.type === "wireguard") return "site-wireguard"; if (site?.type === "newt") return "site-newt"; - return "site-newt"; + return "site-newt"; }; const getCredentials = () => { @@ -142,12 +157,26 @@ PersistentKeepalive = 5`; - + + + +
+ +
+
+ + {isSecurityFeatureDisabled() && ( + + {t("featureDisabledTooltip")} + + )} +
+
From b6e98632b5efa5efefcae201f1ce7d1048204f3c Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 8 Nov 2025 02:43:47 +0530 Subject: [PATCH 39/70] move re-key API routes to private api --- messages/en-US.json | 2 +- server/private/routers/external.ts | 37 ++++++++++++++----- server/private/routers/re-key/index.ts | 3 ++ .../routers/re-key}/reGenerateClientSecret.ts | 2 +- .../reGenerateExitNodeSecret.ts | 25 ++++++++++++- .../routers/re-key}/reGenerateSiteSecret.ts | 4 +- .../private/routers/remoteExitNode/index.ts | 1 - server/routers/client/index.ts | 3 +- server/routers/external.ts | 13 ------- server/routers/site/index.ts | 3 +- .../[remoteExitNodeId]/credentials/page.tsx | 2 +- .../clients/[clientId]/credentials/page.tsx | 2 +- .../settings/clients/[clientId]/layout.tsx | 12 ++++-- .../sites/[niceId]/credentials/page.tsx | 4 +- .../settings/sites/[niceId]/layout.tsx | 3 +- 15 files changed, 75 insertions(+), 41 deletions(-) create mode 100644 server/private/routers/re-key/index.ts rename server/{routers/client => private/routers/re-key}/reGenerateClientSecret.ts (98%) rename server/private/routers/{remoteExitNode => re-key}/reGenerateExitNodeSecret.ts (85%) rename server/{routers/site => private/routers/re-key}/reGenerateSiteSecret.ts (97%) diff --git a/messages/en-US.json b/messages/en-US.json index c9d55062..e7efb66b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2111,7 +2111,7 @@ "confirm": "Confirm", "regenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials?", "endpoint": "Endpoint", - "id": "Id", + "Id": "Id", "SecretKey": "Secret Key", "featureDisabledTooltip": "This feature is only available in the enterprise plan and require a license to use it." } diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 493e9646..eefd175c 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -23,11 +23,15 @@ import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; import * as logs from "#private/routers/auditLogs"; import * as misc from "#private/routers/misc"; +import * as reKey from "#private/routers/re-key"; import { verifyOrgAccess, verifyUserHasAction, - verifyUserIsServerAdmin + verifyUserIsServerAdmin, + verifySiteAccess, + verifyClientAccess, + verifyClientsEnabled, } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -236,14 +240,6 @@ authenticated.put( remoteExitNode.createRemoteExitNode ); -authenticated.put( - "/org/:orgId/reGenerate-remote-exit-node-secret", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.updateRemoteExitNode), - remoteExitNode.reGenerateExitNodeSecret -); - authenticated.get( "/org/:orgId/remote-exit-nodes", verifyValidLicense, @@ -411,3 +407,26 @@ authenticated.get( logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); + +authenticated.post( + "/re-key/:clientId/regenerate-client-secret", + verifyClientsEnabled, + verifyClientAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + reKey.reGenerateClientSecret +); + +authenticated.post( + "/re-key/:siteId/regenerate-site-secret", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.reGenerateSecret), + reKey.reGenerateSiteSecret +); + +authenticated.put( + "/re-key/:orgId/reGenerate-remote-exit-node-secret", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + reKey.reGenerateExitNodeSecret +); diff --git a/server/private/routers/re-key/index.ts b/server/private/routers/re-key/index.ts new file mode 100644 index 00000000..7e04d9e4 --- /dev/null +++ b/server/private/routers/re-key/index.ts @@ -0,0 +1,3 @@ +export * from "./reGenerateClientSecret"; +export * from "./reGenerateSiteSecret"; +export * from "./reGenerateExitNodeSecret"; \ No newline at end of file diff --git a/server/routers/client/reGenerateClientSecret.ts b/server/private/routers/re-key/reGenerateClientSecret.ts similarity index 98% rename from server/routers/client/reGenerateClientSecret.ts rename to server/private/routers/re-key/reGenerateClientSecret.ts index 2bce396a..d16d433b 100644 --- a/server/routers/client/reGenerateClientSecret.ts +++ b/server/private/routers/re-key/reGenerateClientSecret.ts @@ -29,7 +29,7 @@ export type ReGenerateSecretBody = z.infer; registry.registerPath({ method: "post", - path: "/client/{clientId}/regenerate-secret", + path: "/re-key/{clientId}/regenerate-client-secret", description: "Regenerate a client's OLM credentials by its client ID.", tags: [OpenAPITags.Client], request: { diff --git a/server/private/routers/remoteExitNode/reGenerateExitNodeSecret.ts b/server/private/routers/re-key/reGenerateExitNodeSecret.ts similarity index 85% rename from server/private/routers/remoteExitNode/reGenerateExitNodeSecret.ts rename to server/private/routers/re-key/reGenerateExitNodeSecret.ts index b3785d2e..1503e75a 100644 --- a/server/private/routers/remoteExitNode/reGenerateExitNodeSecret.ts +++ b/server/private/routers/re-key/reGenerateExitNodeSecret.ts @@ -23,7 +23,11 @@ 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 { paramsSchema } from "./createRemoteExitNode"; +import { OpenAPITags, registry } from "@server/openApi"; + +export const paramsSchema = z.object({ + orgId: z.string() +}); const bodySchema = z .object({ @@ -32,6 +36,25 @@ const bodySchema = z }) .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, diff --git a/server/routers/site/reGenerateSiteSecret.ts b/server/private/routers/re-key/reGenerateSiteSecret.ts similarity index 97% rename from server/routers/site/reGenerateSiteSecret.ts rename to server/private/routers/re-key/reGenerateSiteSecret.ts index 7965b6f8..1d046933 100644 --- a/server/routers/site/reGenerateSiteSecret.ts +++ b/server/private/routers/re-key/reGenerateSiteSecret.ts @@ -9,7 +9,7 @@ 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 "../gerbil/peers"; +import { addPeer } from "@server/routers/gerbil/peers"; const updateSiteParamsSchema = z @@ -31,7 +31,7 @@ const updateSiteBodySchema = z registry.registerPath({ method: "post", - path: "/site/{siteId}/regenerate-secret", + path: "/re-key/{siteId}/regenerate-site-secret", description: "Regenerate a site's Newt or WireGuard credentials by its site ID.", tags: [OpenAPITags.Site], request: { diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 7c001098..2a04f9d9 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -21,4 +21,3 @@ export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; -export * from "./reGenerateExitNodeSecret"; diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 9f97446e..385c7bed 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -3,5 +3,4 @@ export * from "./createClient"; export * from "./deleteClient"; export * from "./listClients"; export * from "./updateClient"; -export * from "./getClient"; -export * from "./reGenerateClientSecret"; \ No newline at end of file +export * from "./getClient"; \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index c2c518fa..f500f483 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -178,13 +178,6 @@ authenticated.post( client.updateClient, ); -authenticated.post( - "/client/:clientId/regenerate-secret", - verifyClientsEnabled, - verifyClientAccess, - verifyUserHasAction(ActionsEnum.reGenerateSecret), - client.reGenerateClientSecret -); // authenticated.get( // "/site/:siteId/roles", @@ -200,12 +193,6 @@ authenticated.post( site.updateSite, ); -authenticated.post( - "/site/:siteId/regenerate-secret", - verifySiteAccess, - verifyUserHasAction(ActionsEnum.reGenerateSecret), - site.reGenerateSiteSecret -); authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 9b8b89cb..b97557a8 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -5,5 +5,4 @@ export * from "./updateSite"; export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; -export * from "./socketIntegration"; -export * from "./reGenerateSiteSecret"; \ No newline at end of file +export * from "./socketIntegration"; \ No newline at end of file diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index 0fcdcbbb..115b1bd3 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -59,7 +59,7 @@ export default function CredentialsPage() { setCredentials(data); await api.put>( - `/org/${orgId}/reGenerate-remote-exit-node-secret`, + `/re-key/${orgId}/reGenerate-remote-exit-node-secret`, { remoteExitNodeId: remoteExitNode.remoteExitNodeId, secret: data.secret, diff --git a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx index 024c539a..f14d49e4 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/credentials/page.tsx @@ -52,7 +52,7 @@ export default function CredentialsPage() { const data = res.data.data; setClientDefaults(data); - await api.post(`/client/${client?.clientId}/regenerate-secret`, { + await api.post(`/re-key/${client?.clientId}/regenerate-client-secret`, { olmId: data.olmId, secret: data.olmSecret, }); diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx index dc4ef0b4..257cb20f 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -8,6 +8,7 @@ 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; @@ -38,10 +39,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { title: t('general'), href: `/{orgId}/settings/clients/{clientId}/general` }, - { - title: t('credentials'), - href: `/{orgId}/settings/clients/{clientId}/credentials` - } + ...(build === 'enterprise' + ? [{ + title: t('credentials'), + href: `/{orgId}/settings/clients/{clientId}/credentials` + }, + ] + : []), ]; return ( diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index 8351c730..6dcee413 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -95,7 +95,7 @@ PersistentKeepalive = 5`; ); } - await api.post(`/site/${site?.siteId}/regenerate-secret`, { + await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, { type: "wireguard", subnet: res.data.data.subnet, exitNodeId: res.data.data.exitNodeId, @@ -109,7 +109,7 @@ PersistentKeepalive = 5`; const data = res.data.data; setSiteDefaults(data); - await api.post(`/site/${site?.siteId}/regenerate-secret`, { + await api.post(`/re-key/${site?.siteId}/regenerate-site-secret`, { type: "newt", newtId: data.newtId, newtSecret: data.newtSecret diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 01008dab..8ef00410 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -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; @@ -37,7 +38,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { title: t('general'), href: `/${params.orgId}/settings/sites/${params.niceId}/general`, }, - ...(site.type !== 'local' + ...(site.type !== 'local' && build === 'enterprise' ? [ { title: t('credentials'), From 94e1c534ca5a574ee58d868075626db01c4d6e89 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 8 Nov 2025 00:19:30 +0100 Subject: [PATCH 40/70] =?UTF-8?q?=F0=9F=92=84=20add=20link=20to=20read=20m?= =?UTF-8?q?ore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProductUpdates.tsx | 33 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 6d8a39f6..6c56d69d 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -9,6 +9,7 @@ import { ArrowRight, BellIcon, ChevronRightIcon, + ExternalLinkIcon, RocketIcon, XIcon } from "lucide-react"; @@ -181,9 +182,14 @@ function ProductUpdatesListPopup({
-

- {t("productUpdateWhatsNew")} -

+
+

+ {t("productUpdateWhatsNew")} +

+
+ +
+
-
- -
@@ -267,9 +270,21 @@ function ProductUpdatesListPopup({
- - {update.contents} - +
+ + {update.contents}{" "} + {update.link && ( + + Read more{" "} + + + )} + +