diff --git a/design-system/_ds_manifest.json b/design-system/_ds_manifest.json
index 41ccfa2c..d5db586f 100644
--- a/design-system/_ds_manifest.json
+++ b/design-system/_ds_manifest.json
@@ -61,6 +61,10 @@
"name": "Spinner",
"sourcePath": "components/feedback/Spinner.jsx"
},
+ {
+ "name": "StatusCallout",
+ "sourcePath": "components/feedback/StatusCallout.jsx"
+ },
{
"name": "StatusPill",
"sourcePath": "components/feedback/StatusPill.jsx"
@@ -612,6 +616,24 @@
"kind": "color",
"definedIn": "tokens/colors.css"
},
+ {
+ "name": "--success-soft",
+ "value": "rgba(52, 211, 153, 0.12)",
+ "kind": "color",
+ "definedIn": "tokens/colors.css"
+ },
+ {
+ "name": "--warning-soft",
+ "value": "rgba(245, 166, 35, 0.12)",
+ "kind": "color",
+ "definedIn": "tokens/colors.css"
+ },
+ {
+ "name": "--error-soft",
+ "value": "rgba(243, 105, 127, 0.12)",
+ "kind": "color",
+ "definedIn": "tokens/colors.css"
+ },
{
"name": "--shadow",
"value": "rgba(0, 0, 0, 0.45)",
diff --git a/design-system/components/feedback/StatusCallout.d.ts b/design-system/components/feedback/StatusCallout.d.ts
new file mode 100644
index 00000000..41d1f9c7
--- /dev/null
+++ b/design-system/components/feedback/StatusCallout.d.ts
@@ -0,0 +1,22 @@
+import { CSSProperties, ReactNode } from "react";
+
+export type StatusCalloutTone = "error" | "warning" | "success" | "info";
+
+export interface StatusCalloutProps {
+ /** Severity tone. Drives the tint, border, icon color, and default a11y role. */
+ tone?: StatusCalloutTone;
+ /** Decorative leading glyph/icon rendered in a soft chip (aria-hidden). */
+ icon?: ReactNode;
+ /** Emphasized title line. */
+ title?: ReactNode;
+ /** Body copy. */
+ children?: ReactNode;
+ /** Action row (buttons/links) rendered under the body. */
+ actions?: ReactNode;
+ /** Override the ARIA role (defaults: `alert` for error, `status` otherwise). */
+ role?: string;
+ style?: CSSProperties;
+ [key: string]: unknown;
+}
+
+export declare function StatusCallout(props: StatusCalloutProps): JSX.Element;
diff --git a/design-system/components/feedback/StatusCallout.jsx b/design-system/components/feedback/StatusCallout.jsx
new file mode 100644
index 00000000..ba2e52a1
--- /dev/null
+++ b/design-system/components/feedback/StatusCallout.jsx
@@ -0,0 +1,108 @@
+import React from "react";
+
+/**
+ * StatusCallout — a soft-tinted inline banner (icon + title + text + actions)
+ * for warn / error / info / success states. Larger and more structured than the
+ * inline `Alert`: it carries a rounded icon chip, an emphasized title, body copy,
+ * and a row of actions, and is used to surface fail-closed guards and load errors.
+ *
+ * Design-system-first: every color/spacing/type value is a token. RTL-safe via
+ * CSS logical properties (gap + logical padding), so it mirrors automatically.
+ *
+ * A11y: defaults `role` by tone — `alert` for errors (assertively announced),
+ * `status` for the rest. The icon is decorative (`aria-hidden`); meaning lives in
+ * the title + text. Pass an explicit `role`/`aria-label` to override.
+ */
+const TONES = {
+ error: { color: "var(--error-color)", soft: "var(--error-soft)", role: "alert" },
+ warning: { color: "var(--warning-color)", soft: "var(--warning-soft)", role: "status" },
+ success: { color: "var(--success-color)", soft: "var(--success-soft)", role: "status" },
+ info: { color: "var(--accent-color)", soft: "var(--accent-soft)", role: "status" },
+};
+
+export function StatusCallout({
+ tone = "info",
+ icon,
+ title,
+ children,
+ actions,
+ role,
+ style,
+ ...rest
+}) {
+ const t = TONES[tone] || TONES.info;
+ return (
+
+ {icon != null && (
+
+ {icon}
+
+ )}
+
+ {title && (
+
+ {title}
+
+ )}
+ {children != null && (
+
+ {children}
+
+ )}
+ {actions != null && (
+
+ {actions}
+
+ )}
+
+
+ );
+}
diff --git a/design-system/index.d.ts b/design-system/index.d.ts
index 4aba68c9..bedbccb3 100644
--- a/design-system/index.d.ts
+++ b/design-system/index.d.ts
@@ -29,6 +29,8 @@ export { Skeleton } from './components/feedback/Skeleton'
export type * from './components/feedback/Skeleton'
export { Spinner } from './components/feedback/Spinner'
export type * from './components/feedback/Spinner'
+export { StatusCallout } from './components/feedback/StatusCallout'
+export type * from './components/feedback/StatusCallout'
export { StatusPill } from './components/feedback/StatusPill'
export type * from './components/feedback/StatusPill'
export { Toast } from './components/feedback/Toast'
diff --git a/design-system/index.js b/design-system/index.js
index 695d3354..cb3b0a57 100644
--- a/design-system/index.js
+++ b/design-system/index.js
@@ -16,6 +16,7 @@ export { EmptyState } from './components/feedback/EmptyState.jsx'
export { Rating } from './components/feedback/Rating.jsx'
export { Skeleton } from './components/feedback/Skeleton.jsx'
export { Spinner } from './components/feedback/Spinner.jsx'
+export { StatusCallout } from './components/feedback/StatusCallout.jsx'
export { StatusPill } from './components/feedback/StatusPill.jsx'
export { Toast } from './components/feedback/Toast.jsx'
export { FieldStatus } from './components/forms/FieldStatus.jsx'
diff --git a/design-system/tokens/colors.css b/design-system/tokens/colors.css
index 190ec682..4154e6f4 100644
--- a/design-system/tokens/colors.css
+++ b/design-system/tokens/colors.css
@@ -81,6 +81,10 @@
--success-color: var(--mint-500);
--warning-color: var(--amber-500);
--error-color: var(--coral-500);
+ /* status soft-tints — inline callout / banner fills (StatusCallout, posture summary) */
+ --success-soft: rgba(52, 211, 153, 0.12);
+ --warning-soft: rgba(245, 166, 35, 0.12);
+ --error-soft: rgba(243, 105, 127, 0.12);
/* elevation */
--shadow: rgba(0, 0, 0, 0.45);
/* modal / dialog scrim — dims the shell behind overlays */
@@ -129,6 +133,9 @@
--success-color: var(--mint-600);
--warning-color: var(--amber-600);
--error-color: var(--coral-600);
+ --success-soft: rgba(16, 185, 129, 0.10);
+ --warning-soft: rgba(217, 131, 19, 0.10);
+ --error-soft: rgba(224, 85, 107, 0.10);
--shadow: rgba(15, 23, 42, 0.12);
--scrim: rgba(15, 23, 42, 0.32);
--seam: linear-gradient(90deg, var(--accent-color) 0%, var(--accent-2) 100%);
diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml
index 29356dc3..749c217f 100644
--- a/docker-compose.e2e.yml
+++ b/docker-compose.e2e.yml
@@ -1,343 +1,347 @@
-# Self-contained E2E stack for OIDC integration testing.
-# No dependency on FuzeInfra — runs its own Postgres and Redis.
-#
-# Two usage modes:
-#
-# 1. OIDC plumbing test (default — no secrets required)
-# Tests the full OIDC stack with a local Authentik user.
-# Add 127.0.0.1 authentik-server to /etc/hosts on the Playwright host.
-# docker compose -f docker-compose.e2e.yml up -d --build
-#
-# 2. Google OAuth E2E test (requires tunnel + Google secrets)
-# Activates cloudflared so real Google callbacks work.
-# docker compose -f docker-compose.e2e.yml --profile tunnel up -d --build
-#
-# Environment variables:
-# AUTHENTIK_EXTERNAL_URL — how browsers reach Authentik
-# default: http://authentik-server:9000
-# tunnel mode: https://auth-dev.fuzefront.com
-# AUTHENTIK_ISSUER_URL — what the backend uses for OIDC discovery
-# default: http://authentik-server:9000/application/o/fuzefront/
-# tunnel mode: https://auth-dev.fuzefront.com/application/o/fuzefront/
-# AUTHENTIK_COOKIE_DOMAIN — cookie scope (default: authentik-server)
-# CLOUDFLARE_TUNNEL_TOKEN — tunnel profile only
-# TUNNEL_HOSTNAME — tunnel profile only (default: auth-dev.fuzefront.com)
-# GOOGLE_DEV_CLIENT_ID — tunnel profile only
-# GOOGLE_DEV_CLIENT_SECRET — tunnel profile only
-# AUTHENTIK_OIDC_CLIENT_SECRET — OIDC provider shared secret (default: e2e-oidc-client-secret)
-#
-# Usage:
-# docker compose -f docker-compose.e2e.yml up -d --build
-# docker compose -f docker-compose.e2e.yml down -v # tear down + wipe volumes
-
-name: fuzefront-e2e
-
-networks:
- e2e:
- driver: bridge
-
-volumes:
- e2e_postgres:
- e2e_redis:
- e2e_authentik_media:
- e2e_authentik_templates:
- e2e_authentik_certs:
-
-services:
- # ── Postgres (shared by backend + Authentik) ──────────────────────────────
- postgres:
- image: postgres:15-alpine
- environment:
- POSTGRES_USER: e2e
- POSTGRES_PASSWORD: e2e_pass
- # postgres image runs *.sh scripts in /docker-entrypoint-initdb.d/ at first start
- volumes:
- - e2e_postgres:/var/lib/postgresql/data
- - ./deploy/e2e/init-postgres.sh:/docker-entrypoint-initdb.d/init.sh:ro
- networks:
- - e2e
- healthcheck:
- test: ['CMD-SHELL', 'pg_isready -U e2e']
- interval: 5s
- timeout: 5s
- retries: 10
- start_period: 10s
-
- # ── Redis (for Authentik) ─────────────────────────────────────────────────
- redis:
- image: redis:7-alpine
- networks:
- - e2e
- healthcheck:
- test: ['CMD', 'redis-cli', 'ping']
- interval: 5s
- timeout: 3s
- retries: 5
-
- # ── Authentik worker (applies blueprints, including Google source + OIDC provider) ─
- authentik-worker:
- image: ghcr.io/goauthentik/server:2024.12.3
- command: worker
- environment:
- AUTHENTIK_REDIS__HOST: redis
- AUTHENTIK_POSTGRESQL__HOST: postgres
- AUTHENTIK_POSTGRESQL__PORT: 5432
- AUTHENTIK_POSTGRESQL__USER: e2e
- AUTHENTIK_POSTGRESQL__PASSWORD: e2e_pass
- AUTHENTIK_POSTGRESQL__NAME: authentik
- AUTHENTIK_SECRET_KEY: e2e-authentik-secret-not-for-prod
- AUTHENTIK_LOG_LEVEL: info
- AUTHENTIK_ERROR_REPORTING__ENABLED: 'false'
- AUTHENTIK_BOOTSTRAP_PASSWORD: E2eAdmin123!
- AUTHENTIK_BOOTSTRAP_TOKEN: e2e-bootstrap-token
- AUTHENTIK_BOOTSTRAP_EMAIL: admin@e2e.local
- # External URL used in Authentik's OIDC metadata (authorization_endpoint etc.)
- # Default: internal Docker hostname so both the browser (via /etc/hosts) and
- # backend (via Docker DNS) reach the same URL and issuer validation passes.
- # Tunnel mode: set to https://auth-dev.fuzefront.com
- AUTHENTIK_HOST: ${AUTHENTIK_EXTERNAL_URL:-http://authentik-server:9000}
- # Google OAuth source (inert when empty; populated by CI secrets for tunnel mode)
- GOOGLE_CLIENT_ID: ${GOOGLE_DEV_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET: ${GOOGLE_DEV_CLIENT_SECRET:-}
- # FuzeFront OIDC provider secret (must match backend AUTHENTIK_CLIENT_SECRET)
- AUTHENTIK_OIDC_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
- volumes:
- - e2e_authentik_media:/media
- - e2e_authentik_templates:/templates
- - e2e_authentik_certs:/certs
- - ./deploy/helm/fuzefront/authentik/blueprints:/blueprints/fuzefront:ro
- networks:
- - e2e
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
-
- # ── Authentik server ──────────────────────────────────────────────────────
- authentik-server:
- image: ghcr.io/goauthentik/server:2024.12.3
- command: server
- environment:
- AUTHENTIK_REDIS__HOST: redis
- AUTHENTIK_POSTGRESQL__HOST: postgres
- AUTHENTIK_POSTGRESQL__PORT: 5432
- AUTHENTIK_POSTGRESQL__USER: e2e
- AUTHENTIK_POSTGRESQL__PASSWORD: e2e_pass
- AUTHENTIK_POSTGRESQL__NAME: authentik
- AUTHENTIK_SECRET_KEY: e2e-authentik-secret-not-for-prod
- AUTHENTIK_LOG_LEVEL: info
- AUTHENTIK_DISABLE_UPDATE_CHECK: 'true'
- AUTHENTIK_ERROR_REPORTING__ENABLED: 'false'
- # External URL: controls the issuer + endpoint URLs in OIDC discovery metadata.
- # Default (plumbing test, no tunnel): http://authentik-server:9000
- # → add 127.0.0.1 authentik-server to /etc/hosts on the Playwright host
- # Tunnel mode: set AUTHENTIK_EXTERNAL_URL=https://auth-dev.fuzefront.com
- AUTHENTIK_HOST: ${AUTHENTIK_EXTERNAL_URL:-http://authentik-server:9000}
- # Cookie domain must match the hostname browsers use to reach Authentik.
- # Default (no tunnel): authentik-server Tunnel mode: auth-dev.fuzefront.com
- AUTHENTIK_COOKIE_DOMAIN: ${AUTHENTIK_COOKIE_DOMAIN:-authentik-server}
- AUTHENTIK_BOOTSTRAP_PASSWORD: E2eAdmin123!
- AUTHENTIK_BOOTSTRAP_TOKEN: e2e-bootstrap-token
- AUTHENTIK_BOOTSTRAP_EMAIL: admin@e2e.local
- GOOGLE_CLIENT_ID: ${GOOGLE_DEV_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET: ${GOOGLE_DEV_CLIENT_SECRET:-}
- AUTHENTIK_OIDC_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
- volumes:
- - e2e_authentik_media:/media
- - e2e_authentik_templates:/templates
- - ./deploy/helm/fuzefront/authentik/blueprints:/blueprints/fuzefront:ro
- ports:
- - '9000:9000'
- networks:
- - e2e
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- authentik-worker:
- condition: service_started
- healthcheck:
- test: ['CMD-SHELL', 'ak healthcheck || exit 1']
- interval: 10s
- timeout: 15s
- retries: 18
- start_period: 120s
-
- # ── Cloudflare tunnel (exposes authentik-server at TUNNEL_HOSTNAME) ───────
- # Only activated with --profile tunnel (Google OAuth E2E only).
- # Plumbing tests (local user) do NOT need the tunnel.
- cloudflared:
- profiles: [tunnel]
- image: cloudflare/cloudflared:2024.12.2
- command: tunnel --no-autoupdate run
- environment:
- TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-}
- networks:
- - e2e
- depends_on:
- authentik-server:
- condition: service_healthy
- restart: on-failure:5
-
- # ── Backend ───────────────────────────────────────────────────────────────
- backend:
- build:
- context: .
- dockerfile: backend/Dockerfile
- ports:
- - '3001:3001'
- environment:
- NODE_ENV: production
- USE_POSTGRES: 'true'
- DB_HOST: postgres
- DB_PORT: '5432'
- DB_NAME: fuzefront_platform
- DB_USER: e2e
- DB_PASSWORD: e2e_pass
- JWT_SECRET: e2e-jwt-secret-not-for-prod
- PORT: '3001'
- # After the OIDC callback the backend redirects the browser here.
- # Must be host-reachable so the browser (Playwright on the host) can follow.
- FRONTEND_URL: http://localhost:4173
- PERMIT_API_KEY: ci-noop
- PERMIT_PDP_URL: http://localhost:7766
- # Authentik OIDC client — matches provider-oidc.yaml blueprint
- AUTHENTIK_CLIENT_ID: fuzefront-oidc-client
- AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
- # Issuer URL for OIDC discovery + token exchange.
- # Default (plumbing test): http://authentik-server:9000/... — reachable from
- # inside Docker via DNS, and from the Playwright host via /etc/hosts entry.
- # Tunnel mode: set AUTHENTIK_ISSUER_URL=https://auth-dev.fuzefront.com/application/o/fuzefront/
- AUTHENTIK_ISSUER_URL: ${AUTHENTIK_ISSUER_URL:-http://authentik-server:9000/application/o/fuzefront/}
- # Authentik redirects the browser here after authentication.
- # localhost:3001 maps to the published backend port — always host-reachable.
- AUTHENTIK_REDIRECT_URI: http://localhost:3001/api/auth/oidc/callback
- networks:
- - e2e
- depends_on:
- postgres:
- condition: service_healthy
- authentik-server:
- condition: service_started
- healthcheck:
- test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3001/health']
- interval: 10s
- timeout: 5s
- retries: 6
- start_period: 30s
-
- # ── Security service ──────────────────────────────────────────────────────
- # The SPA signs in through the provider-agnostic Security API
- # (/api/v1/security/*), which ONLY this service serves — the monolith `backend`
- # above mounts /api/auth, /api/apps, /api/organizations, /api/v1/app-registry
- # and nothing else. Without this container the SPA's login POST 404s, so the
- # sign-in e2e times out waiting for a response that can never arrive. nginx
- # (see deploy/e2e/nginx.e2e.conf) path-routes /api/v1/security/ here, mirroring
- # the prod Ingress which sends /api/v1/security → fuzefront-security.
- security:
- build:
- context: .
- dockerfile: backend/security/Dockerfile
- ports:
- - '3002:3002'
- environment:
- NODE_ENV: production
- USE_POSTGRES: 'true'
- DB_HOST: postgres
- DB_PORT: '5432'
- DB_NAME: fuzefront_platform
- DB_USER: e2e
- DB_PASSWORD: e2e_pass
- # Must match the monolith: both mint/validate the same platform JWT.
- JWT_SECRET: e2e-jwt-secret-not-for-prod
- PORT: '3002'
- FRONTEND_URL: http://localhost:4173
- PERMIT_API_KEY: ci-noop
- PERMIT_PDP_URL: http://localhost:7766
- # Password sign-in is brokered SERVER-side through Authentik's
- # flow-executor (no browser redirect), so these are required even for the
- # plain email/password e2e — not just the social flow.
- AUTHENTIK_CLIENT_ID: fuzefront-oidc-client
- AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
- AUTHENTIK_ISSUER_URL: ${AUTHENTIK_ISSUER_URL:-http://authentik-server:9000/application/o/fuzefront/}
- # Server-side calls (flow-executor, token, userinfo, jwks) go over the
- # compose network rather than back out through a published port.
- AUTHENTIK_BASE_URL: http://authentik-server:9000
- # Native IdP paths — no reverse-proxy prefix (matches the prod ingress).
- SECURITY_IDP_PROXY_PREFIX: ''
- # MUST be a redirect_uri registered on the OIDC provider (matching_mode:
- # strict), or Authentik rejects the authorize call and password login
- # fails — password sign-in is a full authorize→code→exchange, not just a
- # flow-executor call. It does NOT need to be browser-reachable: the server
- # follows that 302 itself and lifts `code` out of the Location header.
- # So we reuse the shared, registered /api/auth/oidc/callback — exactly what
- # prod's security service does (values.authentik.oidc.redirectUri points at
- # https://app.fuzefront.com/api/auth/oidc/callback). The registered set
- # lives in authentik/blueprints/provider-oidc.yaml.
- AUTHENTIK_REDIRECT_URI: http://localhost:3001/api/auth/oidc/callback
- networks:
- - e2e
- depends_on:
- postgres:
- condition: service_healthy
- authentik-server:
- condition: service_started
- # Serialise migrations. This service runs the SAME 001-009 chain against
- # the SAME `knex_migrations` table as the monolith (see
- # backend/security/src/index.ts). Started concurrently, the two race for
- # the migration lock and one dies — which is exactly what happened when
- # this container was first added: `backend exited (1)` took the whole
- # stack down with it. Waiting for backend to be healthy means the chain is
- # already applied and this service's run is a no-op.
- backend:
- condition: service_healthy
- healthcheck:
- test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3002/health']
- interval: 10s
- timeout: 5s
- retries: 6
- start_period: 30s
-
- # ── Frontend ──────────────────────────────────────────────────────────────
- # Built from repo root (Dockerfile requires workspace context for @fuzefront/* aliases).
- frontend:
- build:
- context: .
- dockerfile: frontend/Dockerfile
- args:
- # SAME-ORIGIN, deliberately empty → the SPA uses relative URLs and every
- # API call goes through this container's nginx, which path-routes
- # /api/v1/security/ → security:3002 and everything else → backend:3001.
- #
- # This previously hard-coded http://localhost:3001, which pointed the
- # browser straight at the MONOLITH and bypassed nginx entirely. The
- # monolith does not serve /api/v1/security/*, so GET /methods 404'd, the
- # SPA saw no `social`, the Google button never rendered and sign-in was
- # impossible — the e2e could not exercise auth at all. It also made the
- # nginx routing dead code.
- #
- # Same-origin is also the documented contract (CLAUDE.md: "the frontend
- # talks to the API on a same-origin API base (no cross-origin base URL)…
- # never hard-code an absolute API host") and is what prod does, so this
- # keeps the e2e faithful to prod instead of testing a shape we never ship.
- VITE_API_URL: ''
- ports:
- - '4173:8080'
- # Override the baked-in nginx.conf with the E2E variant that routes all
- # /api/* to backend:3001 instead of the K8s service names
- # (fuzefront-security / fuzefront-applications / fuzefront-backend) that
- # only exist in the Kubernetes deployment and cause nginx to fail to start
- # in the docker-compose E2E stack.
- volumes:
- - ./deploy/e2e/nginx.e2e.conf:/etc/nginx/nginx.conf:ro
- networks:
- - e2e
- depends_on:
- backend:
- condition: service_healthy
- # nginx resolves upstreams at startup, so it fails to boot if `security`
- # is not up yet — and without it the SPA cannot sign in at all.
- security:
- condition: service_healthy
+# Self-contained E2E stack for OIDC integration testing.
+# No dependency on FuzeInfra — runs its own Postgres and Redis.
+#
+# Two usage modes:
+#
+# 1. OIDC plumbing test (default — no secrets required)
+# Tests the full OIDC stack with a local Authentik user.
+# Add 127.0.0.1 authentik-server to /etc/hosts on the Playwright host.
+# docker compose -f docker-compose.e2e.yml up -d --build
+#
+# 2. Google OAuth E2E test (requires tunnel + Google secrets)
+# Activates cloudflared so real Google callbacks work.
+# docker compose -f docker-compose.e2e.yml --profile tunnel up -d --build
+#
+# Environment variables:
+# AUTHENTIK_EXTERNAL_URL — how browsers reach Authentik
+# default: http://authentik-server:9000
+# tunnel mode: https://auth-dev.fuzefront.com
+# AUTHENTIK_ISSUER_URL — what the backend uses for OIDC discovery
+# default: http://authentik-server:9000/application/o/fuzefront/
+# tunnel mode: https://auth-dev.fuzefront.com/application/o/fuzefront/
+# AUTHENTIK_COOKIE_DOMAIN — cookie scope (default: authentik-server)
+# CLOUDFLARE_TUNNEL_TOKEN — tunnel profile only
+# TUNNEL_HOSTNAME — tunnel profile only (default: auth-dev.fuzefront.com)
+# GOOGLE_DEV_CLIENT_ID — tunnel profile only
+# GOOGLE_DEV_CLIENT_SECRET — tunnel profile only
+# AUTHENTIK_OIDC_CLIENT_SECRET — OIDC provider shared secret (default: e2e-oidc-client-secret)
+#
+# Usage:
+# docker compose -f docker-compose.e2e.yml up -d --build
+# docker compose -f docker-compose.e2e.yml down -v # tear down + wipe volumes
+
+name: fuzefront-e2e
+
+networks:
+ e2e:
+ driver: bridge
+
+volumes:
+ e2e_postgres:
+ e2e_redis:
+ e2e_authentik_media:
+ e2e_authentik_templates:
+ e2e_authentik_certs:
+
+services:
+ # ── Postgres (shared by backend + Authentik) ──────────────────────────────
+ postgres:
+ image: postgres:15-alpine
+ environment:
+ POSTGRES_USER: e2e
+ POSTGRES_PASSWORD: e2e_pass
+ # postgres image runs *.sh scripts in /docker-entrypoint-initdb.d/ at first start
+ volumes:
+ - e2e_postgres:/var/lib/postgresql/data
+ - ./deploy/e2e/init-postgres.sh:/docker-entrypoint-initdb.d/init.sh:ro
+ networks:
+ - e2e
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U e2e']
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+
+ # ── Redis (for Authentik) ─────────────────────────────────────────────────
+ redis:
+ image: redis:7-alpine
+ networks:
+ - e2e
+ healthcheck:
+ test: ['CMD', 'redis-cli', 'ping']
+ interval: 5s
+ timeout: 3s
+ retries: 5
+
+ # ── Authentik worker (applies blueprints, including Google source + OIDC provider) ─
+ authentik-worker:
+ image: ghcr.io/goauthentik/server:2024.12.3
+ command: worker
+ environment:
+ AUTHENTIK_REDIS__HOST: redis
+ AUTHENTIK_POSTGRESQL__HOST: postgres
+ AUTHENTIK_POSTGRESQL__PORT: 5432
+ AUTHENTIK_POSTGRESQL__USER: e2e
+ AUTHENTIK_POSTGRESQL__PASSWORD: e2e_pass
+ AUTHENTIK_POSTGRESQL__NAME: authentik
+ AUTHENTIK_SECRET_KEY: e2e-authentik-secret-not-for-prod
+ AUTHENTIK_LOG_LEVEL: info
+ AUTHENTIK_ERROR_REPORTING__ENABLED: 'false'
+ AUTHENTIK_BOOTSTRAP_PASSWORD: E2eAdmin123!
+ AUTHENTIK_BOOTSTRAP_TOKEN: e2e-bootstrap-token
+ AUTHENTIK_BOOTSTRAP_EMAIL: admin@e2e.local
+ # External URL used in Authentik's OIDC metadata (authorization_endpoint etc.)
+ # Default: internal Docker hostname so both the browser (via /etc/hosts) and
+ # backend (via Docker DNS) reach the same URL and issuer validation passes.
+ # Tunnel mode: set to https://auth-dev.fuzefront.com
+ AUTHENTIK_HOST: ${AUTHENTIK_EXTERNAL_URL:-http://authentik-server:9000}
+ # Google OAuth source (inert when empty; populated by CI secrets for tunnel mode)
+ GOOGLE_CLIENT_ID: ${GOOGLE_DEV_CLIENT_ID:-}
+ GOOGLE_CLIENT_SECRET: ${GOOGLE_DEV_CLIENT_SECRET:-}
+ # FuzeFront OIDC provider secret (must match backend AUTHENTIK_CLIENT_SECRET)
+ AUTHENTIK_OIDC_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
+ volumes:
+ - e2e_authentik_media:/media
+ - e2e_authentik_templates:/templates
+ - e2e_authentik_certs:/certs
+ - ./deploy/helm/fuzefront/authentik/blueprints:/blueprints/fuzefront:ro
+ networks:
+ - e2e
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+ # ── Authentik server ──────────────────────────────────────────────────────
+ authentik-server:
+ image: ghcr.io/goauthentik/server:2024.12.3
+ command: server
+ environment:
+ AUTHENTIK_REDIS__HOST: redis
+ AUTHENTIK_POSTGRESQL__HOST: postgres
+ AUTHENTIK_POSTGRESQL__PORT: 5432
+ AUTHENTIK_POSTGRESQL__USER: e2e
+ AUTHENTIK_POSTGRESQL__PASSWORD: e2e_pass
+ AUTHENTIK_POSTGRESQL__NAME: authentik
+ AUTHENTIK_SECRET_KEY: e2e-authentik-secret-not-for-prod
+ AUTHENTIK_LOG_LEVEL: info
+ AUTHENTIK_DISABLE_UPDATE_CHECK: 'true'
+ AUTHENTIK_ERROR_REPORTING__ENABLED: 'false'
+ # External URL: controls the issuer + endpoint URLs in OIDC discovery metadata.
+ # Default (plumbing test, no tunnel): http://authentik-server:9000
+ # → add 127.0.0.1 authentik-server to /etc/hosts on the Playwright host
+ # Tunnel mode: set AUTHENTIK_EXTERNAL_URL=https://auth-dev.fuzefront.com
+ AUTHENTIK_HOST: ${AUTHENTIK_EXTERNAL_URL:-http://authentik-server:9000}
+ # Cookie domain must match the hostname browsers use to reach Authentik.
+ # Default (no tunnel): authentik-server Tunnel mode: auth-dev.fuzefront.com
+ AUTHENTIK_COOKIE_DOMAIN: ${AUTHENTIK_COOKIE_DOMAIN:-authentik-server}
+ AUTHENTIK_BOOTSTRAP_PASSWORD: E2eAdmin123!
+ AUTHENTIK_BOOTSTRAP_TOKEN: e2e-bootstrap-token
+ AUTHENTIK_BOOTSTRAP_EMAIL: admin@e2e.local
+ GOOGLE_CLIENT_ID: ${GOOGLE_DEV_CLIENT_ID:-}
+ GOOGLE_CLIENT_SECRET: ${GOOGLE_DEV_CLIENT_SECRET:-}
+ AUTHENTIK_OIDC_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
+ volumes:
+ - e2e_authentik_media:/media
+ - e2e_authentik_templates:/templates
+ - ./deploy/helm/fuzefront/authentik/blueprints:/blueprints/fuzefront:ro
+ ports:
+ - '9000:9000'
+ networks:
+ - e2e
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ authentik-worker:
+ condition: service_started
+ healthcheck:
+ test: ['CMD-SHELL', 'ak healthcheck || exit 1']
+ interval: 10s
+ timeout: 15s
+ retries: 18
+ start_period: 120s
+
+ # ── Cloudflare tunnel (exposes authentik-server at TUNNEL_HOSTNAME) ───────
+ # Only activated with --profile tunnel (Google OAuth E2E only).
+ # Plumbing tests (local user) do NOT need the tunnel.
+ cloudflared:
+ profiles: [tunnel]
+ image: cloudflare/cloudflared:2024.12.2
+ command: tunnel --no-autoupdate run
+ environment:
+ TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-}
+ networks:
+ - e2e
+ depends_on:
+ authentik-server:
+ condition: service_healthy
+ restart: on-failure:5
+
+ # ── Backend ───────────────────────────────────────────────────────────────
+ backend:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ ports:
+ - '3001:3001'
+ environment:
+ NODE_ENV: production
+ USE_POSTGRES: 'true'
+ DB_HOST: postgres
+ DB_PORT: '5432'
+ DB_NAME: fuzefront_platform
+ DB_USER: e2e
+ DB_PASSWORD: e2e_pass
+ JWT_SECRET: e2e-jwt-secret-not-for-prod
+ PORT: '3001'
+ # After the OIDC callback the backend redirects the browser here.
+ # Must be host-reachable so the browser (Playwright on the host) can follow.
+ FRONTEND_URL: http://localhost:4173
+ PERMIT_API_KEY: ci-noop
+ PERMIT_PDP_URL: http://localhost:7766
+ # Authentik OIDC client — matches provider-oidc.yaml blueprint
+ AUTHENTIK_CLIENT_ID: fuzefront-oidc-client
+ AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
+ # Issuer URL for OIDC discovery + token exchange.
+ # Default (plumbing test): http://authentik-server:9000/... — reachable from
+ # inside Docker via DNS, and from the Playwright host via /etc/hosts entry.
+ # Tunnel mode: set AUTHENTIK_ISSUER_URL=https://auth-dev.fuzefront.com/application/o/fuzefront/
+ AUTHENTIK_ISSUER_URL: ${AUTHENTIK_ISSUER_URL:-http://authentik-server:9000/application/o/fuzefront/}
+ # Authentik redirects the browser here after authentication.
+ # localhost:3001 maps to the published backend port — always host-reachable.
+ AUTHENTIK_REDIRECT_URI: http://localhost:3001/api/auth/oidc/callback
+ networks:
+ - e2e
+ depends_on:
+ postgres:
+ condition: service_healthy
+ authentik-server:
+ condition: service_started
+ healthcheck:
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3001/health']
+ interval: 10s
+ timeout: 5s
+ retries: 6
+ start_period: 30s
+
+ # ── Security service ──────────────────────────────────────────────────────
+ # The SPA signs in through the provider-agnostic Security API
+ # (/api/v1/security/*), which ONLY this service serves — the monolith `backend`
+ # above mounts /api/auth, /api/apps, /api/organizations, /api/v1/app-registry
+ # and nothing else. Without this container the SPA's login POST 404s, so the
+ # sign-in e2e times out waiting for a response that can never arrive. nginx
+ # (see deploy/e2e/nginx.e2e.conf) path-routes /api/v1/security/ here, mirroring
+ # the prod Ingress which sends /api/v1/security → fuzefront-security.
+ security:
+ build:
+ context: .
+ dockerfile: backend/security/Dockerfile
+ ports:
+ - '3002:3002'
+ environment:
+ NODE_ENV: production
+ USE_POSTGRES: 'true'
+ DB_HOST: postgres
+ DB_PORT: '5432'
+ DB_NAME: fuzefront_platform
+ DB_USER: e2e
+ DB_PASSWORD: e2e_pass
+ # Must match the monolith: both mint/validate the same platform JWT.
+ JWT_SECRET: e2e-jwt-secret-not-for-prod
+ PORT: '3002'
+ FRONTEND_URL: http://localhost:4173
+ PERMIT_API_KEY: ci-noop
+ PERMIT_PDP_URL: http://localhost:7766
+ # Password sign-in is brokered SERVER-side through Authentik's
+ # flow-executor (no browser redirect), so these are required even for the
+ # plain email/password e2e — not just the social flow.
+ AUTHENTIK_CLIENT_ID: fuzefront-oidc-client
+ AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret}
+ AUTHENTIK_ISSUER_URL: ${AUTHENTIK_ISSUER_URL:-http://authentik-server:9000/application/o/fuzefront/}
+ # Server-side calls (flow-executor, token, userinfo, jwks) go over the
+ # compose network rather than back out through a published port.
+ AUTHENTIK_BASE_URL: http://authentik-server:9000
+ # Native IdP paths — no reverse-proxy prefix (matches the prod ingress).
+ SECURITY_IDP_PROXY_PREFIX: ''
+ # MUST be a redirect_uri registered on the OIDC provider (matching_mode:
+ # strict), or Authentik rejects the authorize call and password login
+ # fails — password sign-in is a full authorize→code→exchange, not just a
+ # flow-executor call. It does NOT need to be browser-reachable: the server
+ # follows that 302 itself and lifts `code` out of the Location header.
+ # So we reuse the shared, registered /api/auth/oidc/callback — exactly what
+ # prod's security service does (values.authentik.oidc.redirectUri points at
+ # https://app.fuzefront.com/api/auth/oidc/callback). The registered set
+ # lives in authentik/blueprints/provider-oidc.yaml.
+ AUTHENTIK_REDIRECT_URI: http://localhost:3001/api/auth/oidc/callback
+ networks:
+ - e2e
+ depends_on:
+ postgres:
+ condition: service_healthy
+ authentik-server:
+ condition: service_started
+ # Serialise migrations. This service runs the SAME 001-009 chain against
+ # the SAME `knex_migrations` table as the monolith (see
+ # backend/security/src/index.ts). Started concurrently, the two race for
+ # the migration lock and one dies — which is exactly what happened when
+ # this container was first added: `backend exited (1)` took the whole
+ # stack down with it. Waiting for backend to be healthy means the chain is
+ # already applied and this service's run is a no-op.
+ backend:
+ condition: service_healthy
+ healthcheck:
+ test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3002/health']
+ interval: 10s
+ timeout: 5s
+ retries: 6
+ start_period: 30s
+
+ # ── Frontend ──────────────────────────────────────────────────────────────
+ # Built from repo root (Dockerfile requires workspace context for @fuzefront/* aliases).
+ frontend:
+ build:
+ context: .
+ dockerfile: frontend/Dockerfile
+ args:
+ # SAME-ORIGIN, deliberately empty → the SPA uses relative URLs and every
+ # API call goes through this container's nginx, which path-routes
+ # /api/v1/security/ → security:3002 and everything else → backend:3001.
+ #
+ # This previously hard-coded http://localhost:3001, which pointed the
+ # browser straight at the MONOLITH and bypassed nginx entirely. The
+ # monolith does not serve /api/v1/security/*, so GET /methods 404'd, the
+ # SPA saw no `social`, the Google button never rendered and sign-in was
+ # impossible — the e2e could not exercise auth at all. It also made the
+ # nginx routing dead code.
+ #
+ # Same-origin is also the documented contract (CLAUDE.md: "the frontend
+ # talks to the API on a same-origin API base (no cross-origin base URL)…
+ # never hard-code an absolute API host") and is what prod does, so this
+ # keeps the e2e faithful to prod instead of testing a shape we never ship.
+ VITE_API_URL: ''
+ # Flip the account-security hub release flag ON for this e2e run so the
+ # pre-prod RED specs (feature/account-security-red-e2e) can exercise the
+ # gated /account/security route; prod build stays default OFF.
+ VITE_FF_ACCOUNT_SECURITY_HUB: 'true'
+ ports:
+ - '4173:8080'
+ # Override the baked-in nginx.conf with the E2E variant that routes all
+ # /api/* to backend:3001 instead of the K8s service names
+ # (fuzefront-security / fuzefront-applications / fuzefront-backend) that
+ # only exist in the Kubernetes deployment and cause nginx to fail to start
+ # in the docker-compose E2E stack.
+ volumes:
+ - ./deploy/e2e/nginx.e2e.conf:/etc/nginx/nginx.conf:ro
+ networks:
+ - e2e
+ depends_on:
+ backend:
+ condition: service_healthy
+ # nginx resolves upstreams at startup, so it fails to boot if `security`
+ # is not up yet — and without it the SPA cannot sign in at all.
+ security:
+ condition: service_healthy
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 19017536..b18390af 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,87 +1,92 @@
-# Build stage — glibc + Node 20 so Tailwind v4's @tailwindcss/oxide native
-# binding resolves reliably (it is flaky on alpine/musl).
-#
-# IMPORTANT: this image builds from the REPO ROOT context (release.yml passes
-# `context: .` + `file: frontend/Dockerfile`), NOT from ./frontend. frontend/
-# vite.config.ts aliases the @fuzefront/* UI packages (i18n, identity-ui,
-# design-system, chat-ui) to their SOURCE under ../packages/*/src. Those sources
-# live outside frontend/, so the build needs the whole workspace layout. Building
-# with `context: ./frontend` is what broke the prod frontend image: vite resolved
-# `../packages/i18n/src/index.ts` to a path that did not exist in the context.
-FROM node:20-bookworm-slim AS build
-
-WORKDIR /app
-
-# No GitHub Packages token is needed: every @fuzefront/* package resolves from the
-# local workspace (members + file: deps + vite source aliases), so the install never
-# hits the private registry. (Verified by a clean local build with no token.) This
-# avoids baking a credential into an image layer/cache that gets pushed to GHCR.
-
-# Copy the whole monorepo (node_modules/.git excluded via .dockerignore). The
-# frontend's vite config aliases the @fuzefront/* UI packages (identity-ui, i18n,
-# design-system, chat-client, chat-ui) to their SOURCE under ../packages/* and
-# ../design-system, so the build needs the full workspace, not just frontend/.
-COPY . .
-
-# Workspace install at the ROOT so every workspace member's deps hoist into
-# /app/node_modules — e.g. identity-ui's @tanstack/react-table. This mirrors local
-# dev (root `npm install`); a frontend-only install can't see these transitive deps
-# and is exactly what broke the prod frontend image. Drop the host (Windows)
-# lockfile so linux-native optional deps (e.g. @tailwindcss/oxide) re-resolve
-# (npm optional-deps bug npm/cli#4828).
-RUN rm -f package-lock.json && npm install
-
-# Frontend is NOT a workspace member — install its own deps (incl. the chat-*
-# file: packages) and build. vite resolves the aliased package SOURCES from
-# /app/packages + /app/design-system and their transitive deps from the hoisted
-# /app/node_modules.
-WORKDIR /app/frontend
-
-# Accept build arguments. VITE_API_URL defaults EMPTY so the app resolves the API
-# same-origin (window.location.origin) unless a caller overrides it — correct for
-# the CF-edge/same-origin prod model.
-ARG VITE_API_URL=
-ARG VITE_APP_TITLE="FuzeFront Platform"
-
-# Set environment variables for build
-ENV VITE_API_URL=$VITE_API_URL
-ENV VITE_APP_TITLE=$VITE_APP_TITLE
-
-RUN rm -f package-lock.json && npm install
-
-# Build the application
-RUN npm run build
-
-# Production stage with Nginx
-FROM nginx:alpine AS production
-
-# Install dumb-init for proper signal handling
-RUN apk add --no-cache dumb-init
-
-# Create non-root user
-RUN addgroup -g 1001 -S nginx-user && \
- adduser -S frontend -u 1001 -G nginx-user
-
-# Copy built application
-COPY --from=build /app/frontend/dist /usr/share/nginx/html
-
-# Copy custom Nginx configuration
-COPY frontend/nginx.conf /etc/nginx/nginx.conf
-
-# Create required directories and set permissions
-RUN mkdir -p /var/cache/nginx /var/run && \
- chown -R frontend:nginx-user /var/cache/nginx /var/run /var/log/nginx /usr/share/nginx/html
-
-# Expose port
-EXPOSE 8080
-
-# Health check
-HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
-
-# Switch to non-root user
-USER frontend
-
-# Start Nginx
-ENTRYPOINT ["dumb-init", "--"]
-CMD ["nginx", "-g", "daemon off;"]
+# Build stage — glibc + Node 20 so Tailwind v4's @tailwindcss/oxide native
+# binding resolves reliably (it is flaky on alpine/musl).
+#
+# IMPORTANT: this image builds from the REPO ROOT context (release.yml passes
+# `context: .` + `file: frontend/Dockerfile`), NOT from ./frontend. frontend/
+# vite.config.ts aliases the @fuzefront/* UI packages (i18n, identity-ui,
+# design-system, chat-ui) to their SOURCE under ../packages/*/src. Those sources
+# live outside frontend/, so the build needs the whole workspace layout. Building
+# with `context: ./frontend` is what broke the prod frontend image: vite resolved
+# `../packages/i18n/src/index.ts` to a path that did not exist in the context.
+FROM node:20-bookworm-slim AS build
+
+WORKDIR /app
+
+# No GitHub Packages token is needed: every @fuzefront/* package resolves from the
+# local workspace (members + file: deps + vite source aliases), so the install never
+# hits the private registry. (Verified by a clean local build with no token.) This
+# avoids baking a credential into an image layer/cache that gets pushed to GHCR.
+
+# Copy the whole monorepo (node_modules/.git excluded via .dockerignore). The
+# frontend's vite config aliases the @fuzefront/* UI packages (identity-ui, i18n,
+# design-system, chat-client, chat-ui) to their SOURCE under ../packages/* and
+# ../design-system, so the build needs the full workspace, not just frontend/.
+COPY . .
+
+# Workspace install at the ROOT so every workspace member's deps hoist into
+# /app/node_modules — e.g. identity-ui's @tanstack/react-table. This mirrors local
+# dev (root `npm install`); a frontend-only install can't see these transitive deps
+# and is exactly what broke the prod frontend image. Drop the host (Windows)
+# lockfile so linux-native optional deps (e.g. @tailwindcss/oxide) re-resolve
+# (npm optional-deps bug npm/cli#4828).
+RUN rm -f package-lock.json && npm install
+
+# Frontend is NOT a workspace member — install its own deps (incl. the chat-*
+# file: packages) and build. vite resolves the aliased package SOURCES from
+# /app/packages + /app/design-system and their transitive deps from the hoisted
+# /app/node_modules.
+WORKDIR /app/frontend
+
+# Accept build arguments. VITE_API_URL defaults EMPTY so the app resolves the API
+# same-origin (window.location.origin) unless a caller overrides it — correct for
+# the CF-edge/same-origin prod model.
+ARG VITE_API_URL=
+ARG VITE_APP_TITLE="FuzeFront Platform"
+# account-security hub release flag (fuzefront.account-security.hub), default OFF —
+# e2e/CI can flip it ON at build time to exercise the gated route; prod stays OFF
+# until the Unleash-backed client replaces this build-time override.
+ARG VITE_FF_ACCOUNT_SECURITY_HUB=false
+
+# Set environment variables for build
+ENV VITE_API_URL=$VITE_API_URL
+ENV VITE_APP_TITLE=$VITE_APP_TITLE
+ENV VITE_FF_ACCOUNT_SECURITY_HUB=$VITE_FF_ACCOUNT_SECURITY_HUB
+
+RUN rm -f package-lock.json && npm install
+
+# Build the application
+RUN npm run build
+
+# Production stage with Nginx
+FROM nginx:alpine AS production
+
+# Install dumb-init for proper signal handling
+RUN apk add --no-cache dumb-init
+
+# Create non-root user
+RUN addgroup -g 1001 -S nginx-user && \
+ adduser -S frontend -u 1001 -G nginx-user
+
+# Copy built application
+COPY --from=build /app/frontend/dist /usr/share/nginx/html
+
+# Copy custom Nginx configuration
+COPY frontend/nginx.conf /etc/nginx/nginx.conf
+
+# Create required directories and set permissions
+RUN mkdir -p /var/cache/nginx /var/run && \
+ chown -R frontend:nginx-user /var/cache/nginx /var/run /var/log/nginx /usr/share/nginx/html
+
+# Expose port
+EXPOSE 8080
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
+
+# Switch to non-root user
+USER frontend
+
+# Start Nginx
+ENTRYPOINT ["dumb-init", "--"]
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b38604a3..9edc0cd5 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -22,6 +22,7 @@ import { WorkspaceProvisioningGate } from './components/WorkspaceProvisioningGat
import CreateOrganizationPage from './pages/CreateOrganizationPage'
import AcceptInvitePage from './pages/AcceptInvitePage'
import BillingPage from './pages/BillingPage'
+import AccountSecurityPage from './pages/AccountSecurityPage'
// Authentication wrapper component
function AuthWrapper({ children }: { children: React.ReactNode }) {
@@ -225,6 +226,7 @@ function AppContent() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/pages/AccountSecurityPage.tsx b/frontend/src/pages/AccountSecurityPage.tsx
new file mode 100644
index 00000000..12778f7b
--- /dev/null
+++ b/frontend/src/pages/AccountSecurityPage.tsx
@@ -0,0 +1,55 @@
+import { useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { AccountSecurityHub } from '@fuzefront/account-security-ui'
+
+/**
+ * Feature-flag gate for the account-security hub
+ * (`fuzefront.account-security.hub`, default OFF). The family flag standard is
+ * Unleash via `@fuzefront/feature-flags`; until the web flag client is wired
+ * into the shell bootstrap this reads a build-time override and defaults OFF, so
+ * production behavior is unchanged. Swap the body for
+ * `getBoolean('fuzefront.account-security.hub', false)` once the web client is
+ * initialized — the flag key and default stay identical.
+ */
+function useAccountSecurityHubFlag(): boolean {
+ return import.meta.env.VITE_FF_ACCOUNT_SECURITY_HUB === 'true'
+}
+
+/**
+ * Host route wrapper for `/account/security`. Mounts the design-system-first
+ * @fuzefront/account-security-ui hub, wiring host navigation + the same-origin
+ * bearer token. The hub itself owns all load/error/guard states.
+ */
+export default function AccountSecurityPage() {
+ const navigate = useNavigate()
+ const enabled = useAccountSecurityHubFlag()
+
+ const onNavigate = useCallback(
+ (route: string) => {
+ navigate(route)
+ },
+ [navigate]
+ )
+
+ const getToken = useCallback(() => localStorage.getItem('authToken'), [])
+
+ // Flag OFF (default): the hub is not exposed. A null render keeps the route
+ // registered without shipping the surface until rollout.
+ if (!enabled) {
+ return (
+
+ )}
+
+ )
+}
diff --git a/packages/account-security-ui/src/components/SecurityCardGridSkeleton.tsx b/packages/account-security-ui/src/components/SecurityCardGridSkeleton.tsx
new file mode 100644
index 00000000..7253b41a
--- /dev/null
+++ b/packages/account-security-ui/src/components/SecurityCardGridSkeleton.tsx
@@ -0,0 +1,56 @@
+import { Skeleton } from '@fuzefront/design-system'
+import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider'
+
+/**
+ * Loading placeholder for the hub: a summary row + a 2×2 card grid of shimmer
+ * blocks. Decorative blocks are aria-hidden; the region is marked busy and
+ * labelled so assistive tech announces the load rather than empty content.
+ */
+export function SecurityCardGridSkeleton() {
+ const { messages: m } = useAccountSecurityI18n()
+ return (
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+ )
+}
diff --git a/packages/account-security-ui/src/components/SecurityHub.tsx b/packages/account-security-ui/src/components/SecurityHub.tsx
new file mode 100644
index 00000000..0b62285a
--- /dev/null
+++ b/packages/account-security-ui/src/components/SecurityHub.tsx
@@ -0,0 +1,177 @@
+import { Badge } from '@fuzefront/design-system'
+import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider'
+import { SecurityPostureSummary } from './SecurityPostureSummary'
+import { SecurityCard } from './SecurityCard'
+import { SetPasswordBanner } from './SetPasswordBanner'
+import { SignInMethodsList } from './SignInMethodsList'
+import { providerDisplayName } from './providers'
+import type { SecurityOverview, SocialConnection } from '../types'
+
+export interface SecurityHubRoutes {
+ password: string
+ twoFactor: string
+ sessions: string
+ tokens: string
+ connected: string
+}
+
+const DEFAULT_ROUTES: SecurityHubRoutes = {
+ password: '/account/security/password',
+ twoFactor: '/account/security/two-factor',
+ sessions: '/account/security/devices',
+ tokens: '/account/security/tokens',
+ connected: '/account/security/connections',
+}
+
+export interface SecurityHubProps {
+ overview: SecurityOverview
+ onNavigate?: (route: string) => void
+ /** Set a password (from the social-only guard, and from the last-method guard). */
+ onSetPassword?: () => void
+ /**
+ * Unlink a social provider. Should reject with an HttpError(409) when it is
+ * the account's last sign-in method — SignInMethodsList then renders the
+ * fail-closed last-sign-in-method guard instead of removing the row.
+ */
+ onUnlink?: (provider: SocialConnection['provider']) => Promise
+ /** Link another provider (offered by the last-method guard). */
+ onLinkProvider?: () => void
+ routes?: Partial
+}
+
+/**
+ * Presentational hub (frame 01): posture summary, an optional social-only
+ * set-password guard, and the grid of navigational security cards. Pure — it
+ * takes already-loaded overview data; the orchestrator owns fetching/states.
+ */
+export function SecurityHub({
+ overview,
+ onNavigate,
+ onSetPassword,
+ onUnlink,
+ onLinkProvider,
+ routes,
+}: SecurityHubProps) {
+ const { messages: m, t } = useAccountSecurityI18n()
+ const r = { ...DEFAULT_ROUTES, ...routes }
+ const { connections, methods, activeSessions, activeTokens } = overview
+
+ return (
+
+ )
+}
diff --git a/packages/account-security-ui/src/components/SecurityPostureSummary.tsx b/packages/account-security-ui/src/components/SecurityPostureSummary.tsx
new file mode 100644
index 00000000..c5d21aed
--- /dev/null
+++ b/packages/account-security-ui/src/components/SecurityPostureSummary.tsx
@@ -0,0 +1,111 @@
+import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider'
+import type { PostureLevel, SecurityOverview } from '../types'
+
+export interface SecurityPostureSummaryProps {
+ overview: SecurityOverview
+}
+
+/** Derive the coarse posture from the contract data (no vendor names). */
+export function derivePosture(overview: SecurityOverview): PostureLevel {
+ const { connections, methods } = overview
+ return connections.hasPassword && methods.mfa.enabled ? 'good' : 'attention'
+}
+
+/**
+ * The posture summary banner at the top of the hub. A soft-tinted card with a
+ * status glyph and a one-line human summary derived from connections + methods.
+ * Tokens-only; the seam accent is the brand signature.
+ */
+export function SecurityPostureSummary({ overview }: SecurityPostureSummaryProps) {
+ const { messages: m, t } = useAccountSecurityI18n()
+ const posture = derivePosture(overview)
+ const good = posture === 'good'
+ const { connections, methods, activeSessions } = overview
+
+ const passwordPart = connections.hasPassword ? m.posture.passwordSet : m.posture.passwordMissing
+ const twoFactorPart = methods.mfa.enabled ? m.posture.twoFactorOn : m.posture.twoFactorOff
+ const devicesPart =
+ activeSessions == null
+ ? m.posture.devicesUnknown
+ : t(m.badges.activeDevices, { count: activeSessions }) + ' devices'
+ const linkedCount = connections.providers.length
+ const connectedPart =
+ linkedCount === 0
+ ? m.posture.connectedNone
+ : `${linkedCount} ${linkedCount === 1 ? 'connected account' : 'connected accounts'}`
+
+ const summary = t(m.posture.summary, {
+ password: passwordPart,
+ twoFactor: twoFactorPart,
+ devices: devicesPart,
+ connected: connectedPart,
+ })
+
+ const tone = good ? 'var(--success-color)' : 'var(--warning-color)'
+ const soft = good ? 'var(--success-soft)' : 'var(--warning-soft)'
+
+ return (
+
+
+
+ {good ? '✓' : '!'}
+
+
+
+ {good ? m.posture.good : m.posture.attention}
+
+
+ {summary}
+
+
+
+ )
+}
diff --git a/packages/account-security-ui/src/components/SetPasswordBanner.tsx b/packages/account-security-ui/src/components/SetPasswordBanner.tsx
new file mode 100644
index 00000000..95a3cf8d
--- /dev/null
+++ b/packages/account-security-ui/src/components/SetPasswordBanner.tsx
@@ -0,0 +1,33 @@
+import { StatusCallout, Button } from '@fuzefront/design-system'
+import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider'
+
+export interface SetPasswordBannerProps {
+ /** Invoked when the user chooses to set a password. */
+ onSetPassword: () => void
+}
+
+/**
+ * Social-only account guard (`hasPassword: false`). Surfaced at the top of the
+ * hub: the account has no password sign-in method yet, so password change is
+ * gated behind setting one first. Rendered as a warn StatusCallout.
+ */
+export function SetPasswordBanner({ onSetPassword }: SetPasswordBannerProps) {
+ const { messages: m } = useAccountSecurityI18n()
+ return (
+