From 03e0e8d9c2cfa58537ea77284df9b09e4993fce5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 4 Nov 2025 13:57:55 +0100 Subject: [PATCH 01/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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/28] =?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 aa3f07f1bae235ca93682bdf421423e1345835bd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Nov 2025 20:05:29 +0100 Subject: [PATCH 23/28] =?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 24/28] =?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 94e1c534ca5a574ee58d868075626db01c4d6e89 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 8 Nov 2025 00:19:30 +0100 Subject: [PATCH 25/28] =?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{" "} + + + )} + +