diff --git a/.env b/.env
index c0364e29ae..35417637da 100644
--- a/.env
+++ b/.env
@@ -6,14 +6,20 @@
PROWLER_UI_VERSION="stable"
AUTH_URL=http://localhost:3000
API_BASE_URL=http://prowler-api:8080/api/v1
+# deprecated, use UI_API_BASE_URL
NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL}
+UI_API_BASE_URL=${API_BASE_URL}
+# deprecated, use UI_API_DOCS_URL
NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
+UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
AUTH_TRUST_HOST=true
UI_PORT=3000
# openssl rand -base64 32
AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
-# Google Tag Manager ID
+# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress)
+# deprecated, use UI_GOOGLE_TAG_MANAGER_ID
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=""
+UI_GOOGLE_TAG_MANAGER_ID=""
#### MCP Server ####
PROWLER_MCP_VERSION=stable
@@ -139,10 +145,16 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400
DJANGO_SENTRY_DSN=
DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
-# Sentry settings
-SENTRY_ENVIRONMENT=local
+# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒
+# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's
+# server/edge SDKs.
+UI_SENTRY_DSN=
+UI_SENTRY_ENVIRONMENT=local
SENTRY_RELEASE=local
-NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
+# Reserved runtime public config (registered now; no UI consumer yet)
+# POSTHOG_KEY=
+# POSTHOG_HOST=
+# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0
diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml
index 984256af7f..3210f6e998 100644
--- a/.github/workflows/ui-container-build-push.yml
+++ b/.github/workflows/ui-container-build-push.yml
@@ -32,9 +32,6 @@ env:
PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud
PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui
- # Build args
- NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1
-
permissions: {}
jobs:
@@ -146,7 +143,6 @@ jobs:
context: ${{ env.WORKING_DIRECTORY }}
build-args: |
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }}
- NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }}
push: true
platforms: ${{ matrix.platform }}
tags: |
diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml
index ed407e890d..7165882d62 100644
--- a/.github/workflows/ui-e2e-tests-v2.yml
+++ b/.github/workflows/ui-e2e-tests-v2.yml
@@ -40,7 +40,8 @@ jobs:
AUTH_SECRET: 'fallback-ci-secret-for-testing'
AUTH_TRUST_HOST: true
NEXTAUTH_URL: 'http://localhost:3000'
- NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1'
+ AUTH_URL: 'http://localhost:3000'
+ UI_API_BASE_URL: 'http://localhost:8080/api/v1'
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
@@ -165,7 +166,7 @@ jobs:
timeout=150
elapsed=0
while [ $elapsed -lt $timeout ]; do
- if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then
+ if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml
index 38d6e65ee3..856770722a 100644
--- a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml
+++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml
@@ -11,8 +11,7 @@ data:
{{- else }}
AUTH_URL: {{ .Values.ui.authUrl | quote }}
{{- end }}
- API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
- NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
- NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs"
+ UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
+ UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs"
AUTH_TRUST_HOST: "true"
UI_PORT: {{ .Values.ui.service.port | quote }}
diff --git a/contrib/k8s/helm/prowler-ui/values.yaml b/contrib/k8s/helm/prowler-ui/values.yaml
index d4f2dbe137..aca0178b3a 100644
--- a/contrib/k8s/helm/prowler-ui/values.yaml
+++ b/contrib/k8s/helm/prowler-ui/values.yaml
@@ -21,8 +21,8 @@ fullnameOverride: ""
secrets:
SITE_URL: http://localhost:3000
- API_BASE_URL: http://prowler-api:8080/api/v1
- NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs
+ UI_API_BASE_URL: http://prowler-api:8080/api/v1
+ UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs
AUTH_TRUST_HOST: True
UI_PORT: 3000
# openssl rand -base64 32
diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx
index 0a62251531..9013a32245 100644
--- a/docs/developer-guide/end2end-testing.mdx
+++ b/docs/developer-guide/end2end-testing.mdx
@@ -221,9 +221,9 @@ Before running E2E tests:
```
- **Ensure Prowler API is available**
- - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`).
+ - By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`).
- Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally).
- - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests.
+ - If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests.
- **Ensure Prowler App UI is available**
- Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default).
diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx
new file mode 100644
index 0000000000..1f4d17ee2e
--- /dev/null
+++ b/docs/developer-guide/environment-variables.mdx
@@ -0,0 +1,53 @@
+---
+title: 'Environment Variable Naming Convention'
+---
+
+Prowler is a monorepo composed of several runtime components — Prowler App (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix.
+
+## Component Prefixes
+
+Each component owns a dedicated prefix for the environment variables it reads:
+
+| Component | Prefix | Status |
+|-----------|--------|--------|
+| Prowler App (web UI) | `UI_` | Adopted |
+| Prowler API (backend) | `API_` | Planned |
+| Prowler SDK | `SDK_` | Planned |
+| Prowler MCP Server | `MCP_` | Planned |
+
+## Why Component Prefixes Matter
+
+Component prefixes solve three concrete problems in a shared configuration file:
+
+- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity.
+- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable.
+- **Reduced accidental exposure:** For Prowler App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle.
+
+## Prowler App
+
+Prowler App has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting).
+
+The former build-time variables map to the new runtime variables as follows:
+
+| Former variable | New variable |
+|-----------------|--------------|
+| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` |
+| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` |
+| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
+| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
+| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
+
+The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of the App's runtime configuration.
+
+## Upcoming Breaking Change
+
+
+Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading.
+
+
+Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released.
+
+## Deprecated Names
+
+- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container.
+- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change).
diff --git a/docs/docs.json b/docs/docs.json
index 51c7e739b0..a74aa7e91d 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -417,6 +417,7 @@
"group": "Miscellaneous",
"pages": [
"developer-guide/documentation",
+ "developer-guide/environment-variables",
{
"group": "Testing",
"pages": [
diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx
index 9d60c57321..50cc43a3c0 100644
--- a/docs/troubleshooting.mdx
+++ b/docs/troubleshooting.mdx
@@ -201,35 +201,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan
**Root Cause:**
-Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle.
+The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required.
**Solution:**
-You must **rebuild** the UI Docker image with your external URL:
-
-```bash
-# Clone the repository (if you haven't already)
-git clone https://github.com/prowler-cloud/prowler.git
-cd prowler/ui
-
-# Build with your external URL as a build argument
-docker build \
- --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \
- --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \
- -t prowler-ui-custom:latest \
- --target prod \
- .
-```
-
-Then update your `docker-compose.yml` to use your custom image instead of the pre-built one:
+Set the runtime environment variables to your external URL and restart the UI container:
```yaml
services:
ui:
- image: prowler-ui-custom:latest # Use your custom-built image
+ image: prowlercloud/prowler-ui:stable
+ environment:
+ UI_API_BASE_URL: https://prowler.example.com/api/v1
+ UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs
# ... rest of configuration
```
+The same values can be supplied through your `.env` file:
+
+```bash
+UI_API_BASE_URL=https://prowler.example.com/api/v1
+UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs
+```
+
-The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables.
+Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient.
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 2cb9f92fcb..97f0dc9830 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -28,6 +28,14 @@ All notable changes to the **Prowler UI** are documented in this file.
- Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542)
- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546)
+### 🔄 Changed
+
+- Public SaaS config (Sentry, Google Tag Manager, API base/docs URL) now resolves at container runtime instead of build time; self-hosted deployments set the UI config through the new `UI_`-prefixed env vars (`UI_API_BASE_URL`, `UI_API_DOCS_URL`, `UI_GOOGLE_TAG_MANAGER_ID`, `UI_SENTRY_DSN`, `UI_SENTRY_ENVIRONMENT`), with the previous `NEXT_PUBLIC_*` names still honored as a deprecated fallback [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500)
+
+### 🐞 Fixed
+
+- `ui/.env` template now lists only the canonical `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT` names; the deprecated `NEXT_PUBLIC_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_ENVIRONMENT` entries have been removed [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500)
+
---
## [1.30.0] (Prowler v5.30.0)
diff --git a/ui/Dockerfile b/ui/Dockerfile
index 6cf930ee5e..86673ba046 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -36,12 +36,8 @@ RUN corepack install
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION
ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION}
-ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID
-ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID}
-ARG NEXT_PUBLIC_API_BASE_URL
-ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
-ARG NEXT_PUBLIC_API_DOCS_URL
-ENV NEXT_PUBLIC_API_DOCS_URL=${NEXT_PUBLIC_API_DOCS_URL}
+
+# GTM / API base+docs URLs are runtime container env (prod stage), not build ARGs.
RUN pnpm run build
@@ -78,6 +74,12 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
+# Runtime configuration is read by `node server.js` at container start and is
+# NOT baked into the image. Supply it via your orchestrator (docker-compose,
+# Helm/K8s):
+# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
+# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT
+# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet)
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts
index 39076666dd..7c76fa574b 100644
--- a/ui/__tests__/msw/handlers/attack-paths.ts
+++ b/ui/__tests__/msw/handlers/attack-paths.ts
@@ -10,7 +10,7 @@ import type {
QueryResultAttributes,
} from "@/types/attack-paths";
-const API = process.env.NEXT_PUBLIC_API_BASE_URL;
+const API = process.env.UI_API_BASE_URL;
type JsonApiErrorBody = {
errors: Array<{ detail: string; status: string }>;
diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts
index d95966e680..41118bd6e2 100644
--- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts
+++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts
@@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => {
describe("confirmAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
- vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
+ vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
@@ -104,7 +104,8 @@ describe("confirmAlertRecipient", () => {
});
it("returns the fallback message when the API base URL is missing", async () => {
- // Given
+ // Given - neither the new name nor its legacy fallback is set
+ vi.stubEnv("UI_API_BASE_URL", "");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
// When
@@ -120,6 +121,21 @@ describe("confirmAlertRecipient", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
+ it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => {
+ // Given - only the legacy name is configured
+ vi.stubEnv("UI_API_BASE_URL", undefined);
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1");
+
+ // When
+ const result = await confirmAlertRecipient("token-1");
+
+ // Then
+ expect(result.ok).toBe(true);
+ expect(lastFetchCall().url).toBe(
+ "https://legacy.example.com/api/v1/alerts/recipients/confirm?token=token-1",
+ );
+ });
+
it("returns the fallback message when the request fails", async () => {
// Given
fetchMock.mockRejectedValueOnce(new Error("network down"));
diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts
index b567bb4124..19e9216339 100644
--- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts
+++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts
@@ -1,3 +1,5 @@
+import { readEnv } from "@/lib/runtime-env";
+
interface AlertConfirmApiResponse {
state?: string;
message?: string;
@@ -41,7 +43,7 @@ const toState = (payload: unknown): string => {
export const confirmAlertRecipient = async (
token?: string,
): Promise => {
- const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
+ const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL");
if (!apiBaseUrl) {
return {
ok: false,
diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts
index 8a595fa63e..508625b4be 100644
--- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts
+++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts
@@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => {
describe("unsubscribeAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
- vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
+ vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
@@ -102,4 +102,37 @@ describe("unsubscribeAlertRecipient", () => {
"https://api.example.com/api/v1/alerts/recipients/unsubscribe",
);
});
+
+ it("returns the fallback message when the API base URL is missing", async () => {
+ // Given - neither the new name nor its legacy fallback is set
+ vi.stubEnv("UI_API_BASE_URL", "");
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
+
+ // When
+ const result = await unsubscribeAlertRecipient("token-1");
+
+ // Then
+ expect(result).toEqual({
+ ok: false,
+ state: "missing_api_base_url",
+ message:
+ "We could not process this unsubscribe link. Please try again later.",
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => {
+ // Given - only the legacy name is configured
+ vi.stubEnv("UI_API_BASE_URL", undefined);
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1");
+
+ // When
+ const result = await unsubscribeAlertRecipient("token-1");
+
+ // Then
+ expect(result.ok).toBe(true);
+ expect(lastFetchCall().url).toBe(
+ "https://legacy.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1",
+ );
+ });
});
diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts
index af64165a1e..8c3a4e2a79 100644
--- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts
+++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts
@@ -1,3 +1,5 @@
+import { readEnv } from "@/lib/runtime-env";
+
interface AlertUnsubscribeApiResponse {
state?: string;
message?: string;
@@ -41,7 +43,7 @@ const toState = (payload: unknown): string => {
export const unsubscribeAlertRecipient = async (
token?: string,
): Promise => {
- const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
+ const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL");
if (!apiBaseUrl) {
return {
ok: false,
diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx
index 07fe3a60c3..79a4ce4e89 100644
--- a/ui/app/(auth)/layout.tsx
+++ b/ui/app/(auth)/layout.tsx
@@ -2,12 +2,15 @@ import "@/styles/globals.css";
import { GoogleTagManager } from "@next/third-parties/google";
import { Metadata, Viewport } from "next";
+import { connection } from "next/server";
import { ReactNode, Suspense } from "react";
+import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import { NavigationProgress, Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib";
+import { readEnv } from "@/lib/runtime-env";
import { Providers } from "../providers";
@@ -29,10 +32,27 @@ export const viewport: Viewport = {
],
};
-export default function AuthLayout({ children }: { children: ReactNode }) {
+export default async function AuthLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ // Force dynamic rendering so the read below resolves from the container env
+ // at request time rather than being snapshotted at build (independent of the
+ // island's own connection() call).
+ await connection();
+
+ // Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted
+ const gtmId = readEnv(
+ "UI_GOOGLE_TAG_MANAGER_ID",
+ "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
+ );
+
return (
-
+
+
+
{children}
-
+ {gtmId && }
diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx
index a093232e1c..2554ab02b9 100644
--- a/ui/app/(prowler)/layout.tsx
+++ b/ui/app/(prowler)/layout.tsx
@@ -11,6 +11,7 @@ import {
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
+import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import MainLayout from "@/components/ui/main-layout/main-layout";
import { NavigationProgress } from "@/components/ui/navigation-progress";
import { Toaster } from "@/components/ui/toast";
@@ -76,7 +77,9 @@ export default async function RootLayout({
return (
-
+
+
+
50ms)
- enableInp: true, // Interaction to Next Paint (Core Web Vital)
- }),
- ],
-
- // 🎣 beforeSend Hook - Filter or modify events before sending to Sentry
- ignoreErrors: [
- // Browser extensions
- "top.GLOBALS",
- // Random network errors
- "Network request failed",
- "NetworkError",
- "Failed to fetch",
- // User canceled actions
- "AbortError",
- "Non-Error promise rejection captured",
- // NextAuth expected errors
- "NEXT_REDIRECT",
- // ResizeObserver errors (common browser quirk, not real bugs)
- "ResizeObserver",
- ],
-
- beforeSend(event, hint) {
- // Filter out noise: ResizeObserver errors (common browser quirk, not real bugs)
- if (event.message?.includes("ResizeObserver")) {
- return null; // Don't send to Sentry
- }
-
- // Filter out non-actionable errors
- if (event.exception) {
- const error = hint.originalException;
-
- // Don't send cancelled requests
- if (
- error &&
- typeof error === "object" &&
- "name" in error &&
- error.name === "AbortError"
- ) {
- return null;
- }
-
- // Add additional context for API errors
- if (
- error &&
- typeof error === "object" &&
- "message" in error &&
- typeof error.message === "string" &&
- error.message.includes("Request failed")
- ) {
- event.tags = {
- ...event.tags,
- error_type: "api_error",
- };
- }
- }
-
- return event; // Send to Sentry
- },
- });
-
- // 👤 Set user context (identifies who experienced the error)
- // In production, this will be updated after authentication
- if (isDevelopment) {
- Sentry.setUser({
- id: "dev-user",
- });
- }
-}
diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx
index 7bda5d45fe..41157df06c 100644
--- a/ui/app/providers.tsx
+++ b/ui/app/providers.tsx
@@ -1,8 +1,5 @@
"use client";
-// Import Sentry client-side initialization
-import "@/app/instrumentation.client";
-
import { HeroUIProvider } from "@heroui/system";
import { useRouter } from "next/navigation";
import { SessionProvider } from "next-auth/react";
diff --git a/ui/components/integrations/saml/saml-config-form.tsx b/ui/components/integrations/saml/saml-config-form.tsx
index a995fffceb..935f5412e9 100644
--- a/ui/components/integrations/saml/saml-config-form.tsx
+++ b/ui/components/integrations/saml/saml-config-form.tsx
@@ -18,7 +18,7 @@ import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomServerInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { FormButtons } from "@/components/ui/form";
-import { apiBaseUrl } from "@/lib";
+import { useRuntimeConfig } from "@/hooks/use-runtime-config";
const validateXMLContent = (
xmlContent: string,
@@ -253,10 +253,12 @@ export const SamlConfigForm = ({
reader.readAsText(file);
};
+ const { apiBaseUrl } = useRuntimeConfig();
const trimmedEmailDomain = emailDomain.trim();
- const acsUrl = trimmedEmailDomain
- ? `${apiBaseUrl}/accounts/saml/${trimmedEmailDomain}/acs/`
- : "";
+ const acsUrl =
+ trimmedEmailDomain && apiBaseUrl
+ ? `${apiBaseUrl}/accounts/saml/${trimmedEmailDomain}/acs/`
+ : "";
return (