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 && ( + + )} +
+ {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 ( +
+ This area isn’t available yet. +
+ ) + } + + return ( +
+ navigate('/account/security/password')} + /> +
+ ) +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2171650b..a858455c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -19,6 +19,7 @@ "paths": { "@/*": ["./src/*"], "@fuzefront/identity-ui": ["../packages/identity-ui/dist/index.d.ts"], + "@fuzefront/account-security-ui": ["../packages/account-security-ui/src/index.ts"], "@fuzefront/i18n": ["../packages/i18n/dist/index.d.ts"], "@fuzefront/chat-ui": ["../packages/chat-ui/dist/index.d.ts"], "@fuzefront/chat-client": ["../packages/chat-client/dist/index.d.ts"], diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8e668575..4be331f0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -56,6 +56,13 @@ const appRegistryClientSrc = fileURLToPath( const securityClientSrc = fileURLToPath( new URL('../packages/security/src/index.ts', import.meta.url) ) +// @fuzefront/account-security-ui (packages/account-security-ui) is an unpublished +// file: workspace package whose dist/ is not built in CI — resolve from SOURCE, +// same as identity-ui. It is design-system-first and consumes only the generated +// @fuzefront/security-client TYPES. +const accountSecurityUiSrc = fileURLToPath( + new URL('../packages/account-security-ui/src/index.ts', import.meta.url) +) // Workspace packages resolved from SOURCE (via alias) live outside the frontend/ // directory tree. Rollup walks UP from each file to find node_modules, so it never // reaches frontend/node_modules for those files. This resolver fills the gap: it @@ -80,6 +87,7 @@ export default defineConfig({ resolve: { alias: { '@fuzefront/identity-ui': identityUiSrc, + '@fuzefront/account-security-ui': accountSecurityUiSrc, '@fuzefront/i18n': i18nSrc, // Exact stylesheet subpath must precede the bare '@fuzefront/chat-ui' alias. '@fuzefront/chat-ui/styles.css': chatUiStyles, diff --git a/package-lock.json b/package-lock.json index d8364e94..66d2a84c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "packages/chat-client", "packages/chat-ui", "packages/identity-ui", + "packages/account-security-ui", "packages/i18n", "packages/i18n-translate", "packages/feature-flags", @@ -2167,6 +2168,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fuzefront/account-security-ui": { + "resolved": "packages/account-security-ui", + "link": true + }, "node_modules/@fuzefront/applications-service": { "resolved": "backend/applications", "link": true @@ -21846,197 +21851,1161 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "packages/billing-ui": { - "name": "@fuzefront/billing-ui", - "version": "1.0.0", - "license": "UNLICENSED", + "packages/account-security-ui": { + "name": "@fuzefront/account-security-ui", + "version": "0.1.0", "devDependencies": { - "@fuzefront/billing-client": "file:../../billing-client", - "@stripe/react-stripe-js": "^2.7.0", - "@stripe/stripe-js": "^3.5.0", - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^14.2.1", - "@testing-library/user-event": "^14.5.2", - "@types/react": "^18.3.1", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.1", - "jsdom": "^24.0.0", + "@fuzefront/design-system": "1.0.0", + "@fuzefront/security-client": "0.2.0", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^25.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "tsup": "^8.0.2", - "typescript": "5.1.6", - "vitest": "^1.6.0" + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vite-plugin-dts": "^4.0.0", + "vitest": "^2.1.0" }, "peerDependencies": { - "@fuzefront/billing-client": "^1.0.0", - "@stripe/react-stripe-js": "^2.7.0", - "@stripe/stripe-js": "^3.5.0", + "@fuzefront/design-system": "^1.0.0", + "@fuzefront/security-client": "^0.2.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, - "packages/billing-ui/node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "packages/account-security-ui/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=14.17" + "node": ">=12" } }, - "packages/chat-client": { - "name": "@fuzefront/chat-client", - "version": "1.1.0", + "packages/account-security-ui/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "eventsource-parser": "^1.1.2" - }, - "devDependencies": { - "@types/jest": "^29.5.12", - "@types/node": "18.19.0", - "jest": "29.7.0", - "ts-jest": "29.1.1", - "tsup": "^8.0.2", - "typescript": "5.1.6" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "packages/chat-client/node_modules/@types/node": { - "version": "18.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", - "integrity": "sha512-667KNhaD7U29mT5wf+TZUnrzPrlL2GNQ5N0BMjO2oNULhBxX0/FKCkm6JMu0Jh7Z+1LwUlR21ekd7KhIboNFNw==", + "packages/account-security-ui/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "packages/chat-client/node_modules/ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", + "packages/account-security-ui/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", - "jest-util": "^29.0.0", - "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" - }, - "bin": { - "ts-jest": "cli.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - } + "node": ">=12" } }, - "packages/chat-client/node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "packages/account-security-ui/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14.17" + "node": ">=12" } }, - "packages/chat-ui": { - "name": "@fuzefront/chat-ui", - "version": "1.1.0", + "packages/account-security-ui/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "devDependencies": { - "@fuzefront/chat-client": "file:../chat-client", - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^14.2.1", - "@testing-library/user-event": "^14.5.2", - "@types/react": "^18.3.1", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.1", - "jsdom": "^24.0.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "tsup": "^8.0.2", - "typescript": "5.1.6", - "vitest": "^1.6.0" - }, - "peerDependencies": { - "@fuzefront/chat-client": "^1.1.0", - "react": "^18.0.0", - "react-dom": "^18.0.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" } }, - "packages/chat-ui/node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "packages/account-security-ui/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=14.17" - } - }, - "packages/feature-flags": { - "name": "@fuzefront/feature-flags", - "version": "1.0.0", - "license": "UNLICENSED", - "dependencies": { - "@openfeature/server-sdk": "^1.18.0", - "@openfeature/web-sdk": "^1.5.0" - }, - "devDependencies": { - "@types/jest": "29.5.12", - "@types/node": "18.19.0", - "jest": "29.7.0", - "rimraf": "^5.0.1", - "ts-jest": "29.1.1", - "tsup": "^8.0.2", - "typescript": "5.1.6" - }, - "optionalDependencies": { - "@openfeature/unleash-web-provider": "^0.1.1" + "node": ">=12" } }, - "packages/feature-flags/node_modules/@types/jest": { - "version": "29.5.12", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", - "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "packages/account-security-ui/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "packages/account-security-ui/node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/account-security-ui/node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/account-security-ui/node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/account-security-ui/node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/account-security-ui/node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/account-security-ui/node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/account-security-ui/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "packages/account-security-ui/node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/account-security-ui/node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "packages/account-security-ui/node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "packages/account-security-ui/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/account-security-ui/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "packages/account-security-ui/node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "packages/account-security-ui/node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "packages/account-security-ui/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "packages/account-security-ui/node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "packages/account-security-ui/node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "packages/account-security-ui/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "packages/account-security-ui/node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "packages/account-security-ui/node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/account-security-ui/node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/account-security-ui/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "packages/account-security-ui/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/account-security-ui/node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/account-security-ui/node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/account-security-ui/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/billing-ui": { + "name": "@fuzefront/billing-ui", + "version": "1.0.0", + "license": "UNLICENSED", + "devDependencies": { + "@fuzefront/billing-client": "file:../../billing-client", + "@stripe/react-stripe-js": "^2.7.0", + "@stripe/stripe-js": "^3.5.0", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^24.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tsup": "^8.0.2", + "typescript": "5.1.6", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "@fuzefront/billing-client": "^1.0.0", + "@stripe/react-stripe-js": "^2.7.0", + "@stripe/stripe-js": "^3.5.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "packages/billing-ui/node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/chat-client": { + "name": "@fuzefront/chat-client", + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^1.1.2" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "18.19.0", + "jest": "29.7.0", + "ts-jest": "29.1.1", + "tsup": "^8.0.2", + "typescript": "5.1.6" + } + }, + "packages/chat-client/node_modules/@types/node": { + "version": "18.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", + "integrity": "sha512-667KNhaD7U29mT5wf+TZUnrzPrlL2GNQ5N0BMjO2oNULhBxX0/FKCkm6JMu0Jh7Z+1LwUlR21ekd7KhIboNFNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "packages/chat-client/node_modules/ts-jest": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", + "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "packages/chat-client/node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/chat-ui": { + "name": "@fuzefront/chat-ui", + "version": "1.1.0", + "license": "MIT", + "devDependencies": { + "@fuzefront/chat-client": "file:../chat-client", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^24.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tsup": "^8.0.2", + "typescript": "5.1.6", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "@fuzefront/chat-client": "^1.1.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "packages/chat-ui/node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/feature-flags": { + "name": "@fuzefront/feature-flags", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "@openfeature/server-sdk": "^1.18.0", + "@openfeature/web-sdk": "^1.5.0" + }, + "devDependencies": { + "@types/jest": "29.5.12", + "@types/node": "18.19.0", + "jest": "29.7.0", + "rimraf": "^5.0.1", + "ts-jest": "29.1.1", + "tsup": "^8.0.2", + "typescript": "5.1.6" + }, + "optionalDependencies": { + "@openfeature/unleash-web-provider": "^0.1.1" + } + }, + "packages/feature-flags/node_modules/@types/jest": { + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", "dev": true, "license": "MIT", "dependencies": { @@ -23969,7 +24938,7 @@ }, "packages/security": { "name": "@fuzefront/security-client", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/node": "18.19.0", diff --git a/package.json b/package.json index d8ced502..4f6376b7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "packages/chat-client", "packages/chat-ui", "packages/identity-ui", + "packages/account-security-ui", "packages/i18n", "packages/i18n-translate", "packages/feature-flags", diff --git a/packages/account-security-ui/package.json b/packages/account-security-ui/package.json new file mode 100644 index 00000000..36191b1e --- /dev/null +++ b/packages/account-security-ui/package.json @@ -0,0 +1,45 @@ +{ + "name": "@fuzefront/account-security-ui", + "version": "0.1.0", + "description": "Account security hub UI (posture summary + password / two-factor / devices / API-tokens / connected-accounts cards) for FuzeFront, on the fuse-seam design system", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } + }, + "files": ["dist"], + "sideEffects": false, + "scripts": { + "build": "vite build && tsc -p tsconfig.build.json --emitDeclarationOnly", + "type-check": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": {}, + "peerDependencies": { + "@fuzefront/design-system": "^1.0.0", + "@fuzefront/security-client": "^0.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@fuzefront/design-system": "1.0.0", + "@fuzefront/security-client": "0.2.0", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "jsdom": "^25.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vite-plugin-dts": "^4.0.0", + "vitest": "^2.1.0" + }, + "publishConfig": { "registry": "https://npm.pkg.github.com", "access": "restricted" }, + "repository": { "type": "git", "url": "https://github.com/fuzefront/FuzeFront.git", "directory": "packages/account-security-ui" } +} diff --git a/packages/account-security-ui/src/api/http.ts b/packages/account-security-ui/src/api/http.ts new file mode 100644 index 00000000..fe9b8e4a --- /dev/null +++ b/packages/account-security-ui/src/api/http.ts @@ -0,0 +1,87 @@ +/** + * Minimal fetch helper for the account-security API client. + * + * - Base URL defaults to same-origin (`''`) so the host shell's nginx proxy + * handles `/api`. Never hard-code `http://…` (mixed-content under TLS). + * - Auth token is read lazily via `getToken` so the host controls storage. + * - `HttpError.status` is preserved so callers can branch on the fail-closed + * 409 guards the Security contract returns. + */ +export interface HttpClientOptions { + /** Base URL prefix, e.g. '' (same-origin) or 'https://api.example.com'. Default ''. */ + baseUrl?: string + /** Returns the bearer token to attach, or null/undefined to omit. */ + getToken?: () => string | null | undefined + /** Injectable fetch (for tests). Defaults to global fetch. */ + fetchImpl?: typeof fetch +} + +export class HttpError extends Error { + status: number + body: unknown + /** Provider-neutral error code from the Security contract's ErrorBody, when present. */ + code?: string + constructor(status: number, message: string, body: unknown) { + super(message) + this.name = 'HttpError' + this.status = status + this.body = body + if (body && typeof body === 'object' && 'code' in (body as Record)) { + this.code = String((body as Record).code) + } + } +} + +export class HttpClient { + private baseUrl: string + private getToken?: () => string | null | undefined + private fetchImpl: typeof fetch + + constructor(opts: HttpClientOptions = {}) { + this.baseUrl = opts.baseUrl ?? '' + this.getToken = opts.getToken + this.fetchImpl = opts.fetchImpl ?? globalThis.fetch + } + + async request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { Accept: 'application/json' } + const token = this.getToken?.() + if (token) headers.Authorization = `Bearer ${token}` + let payload: string | undefined + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + payload = JSON.stringify(body) + } + + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers, body: payload }) + const text = await res.text() + const data = text ? safeJson(text) : undefined + + if (!res.ok) { + const message = + (data && typeof data === 'object' && 'error' in (data as Record) + ? String((data as Record).error) + : res.statusText) || `Request failed with ${res.status}` + throw new HttpError(res.status, message, data) + } + return data as T + } + + get(path: string): Promise { + return this.request('GET', path) + } + post(path: string, body?: unknown): Promise { + return this.request('POST', path, body) + } + delete(path: string): Promise { + return this.request('DELETE', path) + } +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return text + } +} diff --git a/packages/account-security-ui/src/api/securityClient.test.ts b/packages/account-security-ui/src/api/securityClient.test.ts new file mode 100644 index 00000000..389f64cb --- /dev/null +++ b/packages/account-security-ui/src/api/securityClient.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest' +import { createAccountSecurityClient } from './securityClient' +import { HttpError } from './http' + +function mockFetch(status: number, body: unknown): typeof fetch { + return vi.fn(async () => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + ) as unknown as typeof fetch +} + +describe('createAccountSecurityClient', () => { + it('reads connections from the contract path with a bearer token', async () => { + const fetchImpl = mockFetch(200, { providers: [{ provider: 'google' }], hasPassword: true }) + const client = createAccountSecurityClient({ fetchImpl, getToken: () => 'tok' }) + const res = await client.getConnections() + expect(res.hasPassword).toBe(true) + const call = (fetchImpl as unknown as ReturnType).mock.calls[0] + expect(call[0]).toBe('/api/v1/security/identity/connections') + expect((call[1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer tok' }) + }) + + it('counts active sessions from the items envelope', async () => { + const fetchImpl = mockFetch(200, { items: [{ id: 'a', current: true }, { id: 'b', current: false }] }) + const client = createAccountSecurityClient({ fetchImpl }) + expect(await client.getActiveSessionCount!()).toBe(2) + }) + + it('surfaces a 409 as an HttpError with the fail-closed code', async () => { + const fetchImpl = mockFetch(409, { error: 'last method', code: 'CONFLICT' }) + const client = createAccountSecurityClient({ fetchImpl }) + await expect(client.getMethods()).rejects.toMatchObject({ + constructor: HttpError, + status: 409, + code: 'CONFLICT', + }) + }) + + it('unlinks a provider via DELETE /social/{provider}/link', async () => { + const fetchImpl = mockFetch(200, { providers: [], hasPassword: true }) + const client = createAccountSecurityClient({ fetchImpl, getToken: () => 'tok' }) + await client.unlinkProvider('google') + const call = (fetchImpl as unknown as ReturnType).mock.calls[0] + expect(call[0]).toBe('/api/v1/security/social/google/link') + expect((call[1] as RequestInit).method).toBe('DELETE') + }) + + it('surfaces the last-sign-in-method 409 as an HttpError from unlinkProvider', async () => { + const fetchImpl = mockFetch(409, { error: 'last method', code: 'last_sign_in_method' }) + const client = createAccountSecurityClient({ fetchImpl }) + await expect(client.unlinkProvider('google')).rejects.toMatchObject({ + constructor: HttpError, + status: 409, + }) + }) +}) diff --git a/packages/account-security-ui/src/api/securityClient.ts b/packages/account-security-ui/src/api/securityClient.ts new file mode 100644 index 00000000..b6098911 --- /dev/null +++ b/packages/account-security-ui/src/api/securityClient.ts @@ -0,0 +1,37 @@ +/** + * Account-security API client — thin, contract-bound wrapper over the Security + * API paths the hub reads. All shapes come from `@fuzefront/security-client`. + */ +import { HttpClient, type HttpClientOptions } from './http' +import type { AccountSecurityClient, AuthMethods, IdentityConnections } from '../types' + +const BASE = '/api/v1/security' + +/** Response envelope of GET /v1/security/sessions ({ items: SessionDevice[] }). */ +interface SessionsResponse { + items: { id: string; current: boolean }[] +} + +export function createAccountSecurityClient( + opts: HttpClientOptions = {} +): AccountSecurityClient { + const http = new HttpClient(opts) + return { + getConnections() { + return http.get(`${BASE}/identity/connections`) + }, + getMethods() { + return http.get(`${BASE}/methods`) + }, + async getActiveSessionCount() { + const res = await http.get(`${BASE}/sessions`) + return Array.isArray(res?.items) ? res.items.length : 0 + }, + async unlinkProvider(provider) { + // Contract: DELETE /v1/security/social/{provider}/link (packages/security/openapi.yaml). + // Fail-closed: rejects with HttpError(status 409) when this is the account's + // last sign-in method — SignInMethodsList surfaces the last-method guard. + await http.delete(`${BASE}/social/${provider}/link`) + }, + } +} diff --git a/packages/account-security-ui/src/components/AccountSecurityHub.test.tsx b/packages/account-security-ui/src/components/AccountSecurityHub.test.tsx new file mode 100644 index 00000000..344f1b71 --- /dev/null +++ b/packages/account-security-ui/src/components/AccountSecurityHub.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AccountSecurityHub } from './AccountSecurityHub' +import type { AccountSecurityClient } from '../types' + +const goodOverviewClient = (): AccountSecurityClient => ({ + getConnections: vi.fn(async () => ({ providers: [{ provider: 'google' }], hasPassword: true })), + getMethods: vi.fn(async () => ({ + password: true, + social: ['google'], + mfa: { enabled: true, types: ['totp'] }, + verification: { email: true, sms: false }, + })), + getActiveSessionCount: vi.fn(async () => 2), + unlinkProvider: vi.fn(async () => {}), +}) + +describe('AccountSecurityHub', () => { + it('shows the loading skeleton, then the hub with the good posture', async () => { + render() + expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument() + await waitFor(() => + expect(screen.getByRole('region', { name: /security posture/i })).toHaveAttribute( + 'data-posture', + 'good' + ) + ) + expect(screen.getByText(/well protected/i)).toBeInTheDocument() + }) + + it('renders the load-error callout and retries on click', async () => { + const failing: AccountSecurityClient = { + getConnections: vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValue({ + providers: [], + hasPassword: true, + }), + getMethods: vi.fn(async () => ({ + password: true, + social: [], + mfa: { enabled: false, types: [] }, + verification: { email: false, sms: false }, + })), + unlinkProvider: vi.fn(async () => {}), + } + render() + const retry = await screen.findByRole('button', { name: /try again/i }) + expect(screen.getByText(/couldn't load/i)).toBeInTheDocument() + await userEvent.click(retry) + await waitFor(() => + expect(screen.getByRole('region', { name: /security posture/i })).toBeInTheDocument() + ) + }) + + it('surfaces the social-only set-password guard when hasPassword is false', async () => { + const socialOnly: AccountSecurityClient = { + getConnections: vi.fn(async () => ({ providers: [{ provider: 'google' }], hasPassword: false })), + getMethods: vi.fn(async () => ({ + password: true, + social: ['google'], + mfa: { enabled: false, types: [] }, + verification: { email: false, sms: false }, + })), + unlinkProvider: vi.fn(async () => {}), + } + const onSetPassword = vi.fn() + render() + const guard = await screen.findByText(/set a password first/i) + expect(guard).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /set a password/i })) + expect(onSetPassword).toHaveBeenCalled() + }) + + it('mirrors to RTL under the he locale', async () => { + render() + const main = await screen.findByRole('main') + expect(main).toHaveAttribute('dir', 'rtl') + }) +}) diff --git a/packages/account-security-ui/src/components/AccountSecurityHub.tsx b/packages/account-security-ui/src/components/AccountSecurityHub.tsx new file mode 100644 index 00000000..54094d7b --- /dev/null +++ b/packages/account-security-ui/src/components/AccountSecurityHub.tsx @@ -0,0 +1,130 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { + AccountSecurityI18nProvider, + type AccountSecurityLocale, +} from '../i18n/AccountSecurityI18nProvider' +import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider' +import { SecurityHub, type SecurityHubRoutes } from './SecurityHub' +import { SecurityCardGridSkeleton } from './SecurityCardGridSkeleton' +import { LoadErrorRetry } from './LoadErrorRetry' +import { createAccountSecurityClient } from '../api/securityClient' +import type { AccountSecurityClient, SecurityOverview } from '../types' + +export interface AccountSecurityHubProps { + /** Injected client (tests/host). Defaults to a same-origin client. */ + client?: AccountSecurityClient + /** Bearer-token accessor for the default client. */ + getToken?: () => string | null | undefined + locale?: AccountSecurityLocale + onNavigate?: (route: string) => void + onSetPassword?: () => void + /** Link another provider (offered by the last-sign-in-method guard). */ + onLinkProvider?: () => void + routes?: Partial +} + +type LoadState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'ready'; overview: SecurityOverview } + +/** + * Flow orchestrator for `/account/security` (frames 01 + 02). Loads the account's + * connections + auth methods (and, best-effort, the active-device count) and + * renders every contract state: loading skeleton, load-error + retry, and the + * hub (which itself surfaces the social-only set-password guard). Fail-closed: + * a load error never shows a partial/permissive hub. + */ +export function AccountSecurityHub(props: AccountSecurityHubProps) { + const locale = props.locale ?? 'en' + return ( + + + + ) +} + +function AccountSecurityHubInner({ + client, + getToken, + onNavigate, + onSetPassword, + onLinkProvider, + routes, +}: AccountSecurityHubProps) { + const { messages: m, dir } = useAccountSecurityI18n() + const [state, setState] = useState({ status: 'loading' }) + + const api = React.useMemo( + () => client ?? createAccountSecurityClient({ getToken }), + [client, getToken] + ) + + const load = useCallback(async () => { + setState({ status: 'loading' }) + try { + const [connections, methods] = await Promise.all([api.getConnections(), api.getMethods()]) + // Active-device count is best-effort; an unknown count renders honestly, + // it never fails the whole hub. + let activeSessions: number | null = null + if (api.getActiveSessionCount) { + try { + activeSessions = await api.getActiveSessionCount() + } catch { + activeSessions = null + } + } + setState({ + status: 'ready', + overview: { + connections, + methods, + activeSessions, + // No token-count endpoint on the Security contract yet — honest unknown. + activeTokens: null, + }, + }) + } catch { + setState({ status: 'error' }) + } + }, [api]) + + useEffect(() => { + void load() + }, [load]) + + return ( +
+

+ {m.page.title} +

+

+ {m.page.subtitle} +

+ + {state.status === 'loading' && } + {state.status === 'error' && void load()} />} + {state.status === 'ready' && ( + api.unlinkProvider(provider)} + onLinkProvider={onLinkProvider} + routes={routes} + /> + )} +
+ ) +} diff --git a/packages/account-security-ui/src/components/ConnectedAccountRow.tsx b/packages/account-security-ui/src/components/ConnectedAccountRow.tsx new file mode 100644 index 00000000..113d50ee --- /dev/null +++ b/packages/account-security-ui/src/components/ConnectedAccountRow.tsx @@ -0,0 +1,53 @@ +import { Badge, Button } from '@fuzefront/design-system' +import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider' +import { providerDisplayName } from './providers' +import type { SocialConnection } from '../types' + +export interface ConnectedAccountRowProps { + connection: SocialConnection + /** Invoked to unlink this provider. May reject with a 409 last-method guard. */ + onUnlink?: (provider: SocialConnection['provider']) => void + /** Disable the unlink control (e.g. an unlink is in flight). */ + busy?: boolean +} + +/** + * One linked social sign-in connection: provider name, a "linked" badge, and an + * unlink control. Layout uses logical properties so it mirrors under RTL. + */ +export function ConnectedAccountRow({ connection, onUnlink, busy }: ConnectedAccountRowProps) { + const { messages: m } = useAccountSecurityI18n() + const name = providerDisplayName(connection.provider) + return ( +
+ + {name} + + + {m.badges.linked} + + {onUnlink && ( + + )} +
+ ) +} diff --git a/packages/account-security-ui/src/components/LoadErrorRetry.tsx b/packages/account-security-ui/src/components/LoadErrorRetry.tsx new file mode 100644 index 00000000..54fbb53a --- /dev/null +++ b/packages/account-security-ui/src/components/LoadErrorRetry.tsx @@ -0,0 +1,30 @@ +import { StatusCallout, Button } from '@fuzefront/design-system' +import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider' + +export interface LoadErrorRetryProps { + onRetry: () => void +} + +/** + * Load-failure state: an error StatusCallout with a single retry action. + * Fail-closed voice — the account is unaffected; the user simply retries. + */ +export function LoadErrorRetry({ onRetry }: LoadErrorRetryProps) { + const { messages: m } = useAccountSecurityI18n() + return ( +
+ + {m.error.retry} + + } + > + {m.error.text} + +
+ ) +} diff --git a/packages/account-security-ui/src/components/SecurityCard.tsx b/packages/account-security-ui/src/components/SecurityCard.tsx new file mode 100644 index 00000000..332c7686 --- /dev/null +++ b/packages/account-security-ui/src/components/SecurityCard.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import type { SecurityCardKey } from '../types' + +export interface SecurityCardProps { + cardKey: SecurityCardKey + /** App-relative route this card navigates to (frame `data-route`). */ + route: string + icon: React.ReactNode + title: string + desc: string + /** Status badge(s) shown in the card meta row. */ + badge?: React.ReactNode + /** Navigation handler; receives the route. Falls back to an anchor href. */ + onNavigate?: (route: string) => void + /** Span the full grid width (the connected-accounts card). */ + fullWidth?: boolean +} + +/** + * One navigational hub card linking to a sibling security surface. Renders as a + * real link (keyboard-focusable, visible focus) with a token-driven hover lift. + * Uses logical properties so the chevron and layout mirror under RTL. + */ +export function SecurityCard({ + cardKey, + route, + icon, + title, + desc, + badge, + onNavigate, + fullWidth, +}: SecurityCardProps) { + const handleClick = (e: React.MouseEvent) => { + if (onNavigate) { + e.preventDefault() + onNavigate(route) + } + } + const handleKey = (e: React.KeyboardEvent) => { + if (onNavigate && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onNavigate(route) + } + } + + return ( + { + e.currentTarget.style.borderColor = 'var(--border-strong)' + e.currentTarget.style.transform = 'translateY(-2px)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--border-color)' + e.currentTarget.style.transform = 'none' + }} + > +
+ + + {title} + + +
+

{desc}

+ {badge != null && ( +
+ {badge} +
+ )} +
+ ) +} 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 ( +
+ + + {!connections.hasPassword && onSetPassword && ( + + )} + +
+ + {m.badges.set} + + ) : ( + + {m.posture.passwordMissing} + + ) + } + /> + + + {m.badges.on} + + ) : ( + {m.posture.twoFactorOff} + ) + } + /> + + + {activeSessions == null + ? m.badges.unknown + : t(m.badges.activeDevices, { count: activeSessions })} + + } + /> + + + {activeTokens == null + ? m.badges.unknown + : t(m.badges.activeTokens, { count: activeTokens })} + + } + /> + + + {connections.providers.map((c) => ( + + {providerDisplayName(c.provider)} · {m.badges.linked} + + ))} + {connections.hasPassword && ( + {m.badges.passwordEnabled} + )} + + } + /> +
+ +
+ +
+
+ ) +} 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 ( +
+
+ ) +} 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 ( +
+ + {m.setPassword.action} + + } + > + {m.setPassword.text} + +
+ ) +} diff --git a/packages/account-security-ui/src/components/SignInMethodsList.test.tsx b/packages/account-security-ui/src/components/SignInMethodsList.test.tsx new file mode 100644 index 00000000..2a4f4c48 --- /dev/null +++ b/packages/account-security-ui/src/components/SignInMethodsList.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SignInMethodsList } from './SignInMethodsList' +import { AccountSecurityI18nProvider } from '../i18n/AccountSecurityI18nProvider' +import { HttpError } from '../api/http' +import type { IdentityConnections } from '../types' + +const connections: IdentityConnections = { + providers: [{ provider: 'google' }], + hasPassword: false, +} + +describe('SignInMethodsList', () => { + it('renders the last-sign-in-method guard when unlink fails with 409', async () => { + const onUnlink = vi.fn(async () => { + throw new HttpError(409, 'last method', { error: 'x', code: 'CONFLICT' }) + }) + render( + + + + ) + await userEvent.click(screen.getByRole('button', { name: /remove/i })) + const guard = await screen.findByText(/keep at least one way to sign in/i) + expect(guard).toBeInTheDocument() + expect( + document.querySelector('[data-guard="last-sign-in-method"]') + ).not.toBeNull() + }) + + it('does not show the guard when unlink succeeds', async () => { + const onUnlink = vi.fn(async () => {}) + render( + + + + ) + await userEvent.click(screen.getByRole('button', { name: /remove/i })) + expect(onUnlink).toHaveBeenCalledWith('google') + expect(document.querySelector('[data-guard="last-sign-in-method"]')).toBeNull() + }) +}) diff --git a/packages/account-security-ui/src/components/SignInMethodsList.tsx b/packages/account-security-ui/src/components/SignInMethodsList.tsx new file mode 100644 index 00000000..2b595298 --- /dev/null +++ b/packages/account-security-ui/src/components/SignInMethodsList.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { Badge, Button, StatusCallout } from '@fuzefront/design-system' +import { useAccountSecurityI18n } from '../i18n/AccountSecurityI18nProvider' +import { ConnectedAccountRow } from './ConnectedAccountRow' +import { HttpError } from '../api/http' +import type { IdentityConnections, SocialConnection } from '../types' + +export interface SignInMethodsListProps { + connections: IdentityConnections + /** + * Unlink a provider. Should reject with an `HttpError` (status 409) when the + * provider is the account's LAST sign-in method — the list then renders the + * fail-closed last-method guard instead of removing the row. + */ + onUnlink?: (provider: SocialConnection['provider']) => Promise + /** Set a password (offered by the last-method guard). */ + onSetPassword?: () => void + /** Link another provider (offered by the last-method guard). */ + onLinkProvider?: () => void +} + +/** + * The account's ways to sign in: the password method (when set) plus each linked + * social connection. Unlinking the LAST remaining method is fail-closed — the + * server returns 409 and this list surfaces the last-sign-in-method guard rather + * than leaving the account with no way to sign in. + */ +export function SignInMethodsList({ + connections, + onUnlink, + onSetPassword, + onLinkProvider, +}: SignInMethodsListProps) { + const { messages: m } = useAccountSecurityI18n() + const [lastMethodGuard, setLastMethodGuard] = useState(false) + const [busy, setBusy] = useState(null) + + const handleUnlink = async (provider: SocialConnection['provider']) => { + if (!onUnlink) return + setBusy(provider) + setLastMethodGuard(false) + try { + await onUnlink(provider) + } catch (err) { + if (err instanceof HttpError && err.status === 409) { + setLastMethodGuard(true) + } else { + throw err + } + } finally { + setBusy(null) + } + } + + return ( +
+

+ {m.methods.heading} +

+ +
+ {connections.hasPassword && ( +
+ + {m.methods.passwordName} + + + {m.badges.set} + +
+ )} + + {connections.providers.map((c) => ( + void handleUnlink(c.provider) : undefined} + busy={busy === c.provider} + /> + ))} +
+ + {lastMethodGuard && ( +
+ + + + + } + > + {m.lastMethod.text} + +
+ )} +
+ ) +} diff --git a/packages/account-security-ui/src/components/StatusCallout.test.tsx b/packages/account-security-ui/src/components/StatusCallout.test.tsx new file mode 100644 index 00000000..d8454d35 --- /dev/null +++ b/packages/account-security-ui/src/components/StatusCallout.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { StatusCallout } from '@fuzefront/design-system' + +/** + * Unit coverage for the DS StatusCallout primitive (owned by design-system/, + * exercised here via the source alias). Verifies tone→role a11y mapping and + * that title/body/actions render. + */ +describe('StatusCallout (design-system primitive)', () => { + it('uses role=alert for the error tone', () => { + render( + + Something failed + + ) + expect(screen.getByRole('alert')).toHaveTextContent('Boom') + expect(screen.getByText('Something failed')).toBeInTheDocument() + }) + + it('uses role=status for non-error tones and renders actions', () => { + render( + Do it}> + A warning + + ) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Do it' })).toBeInTheDocument() + }) + + it('passes through data-* attributes for test hooks', () => { + render() + expect(document.querySelector('[data-guard="set-password-first"]')).not.toBeNull() + }) +}) diff --git a/packages/account-security-ui/src/components/providers.ts b/packages/account-security-ui/src/components/providers.ts new file mode 100644 index 00000000..cb045665 --- /dev/null +++ b/packages/account-security-ui/src/components/providers.ts @@ -0,0 +1,14 @@ +import type { SocialProvider } from '../types' + +/** + * Human display names for social providers. These are the SOCIAL provider names + * shown to the user (e.g. Google) — explicitly present in the approved frames. + * They are NOT identity-vendor names (Authentik/Permit), which never appear. + */ +const PROVIDER_NAMES: Record = { + google: 'Google', +} + +export function providerDisplayName(provider: SocialProvider): string { + return PROVIDER_NAMES[provider] ?? provider +} diff --git a/packages/account-security-ui/src/i18n/AccountSecurityI18nProvider.tsx b/packages/account-security-ui/src/i18n/AccountSecurityI18nProvider.tsx new file mode 100644 index 00000000..0536b3aa --- /dev/null +++ b/packages/account-security-ui/src/i18n/AccountSecurityI18nProvider.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, useMemo } from 'react' +import type { AccountSecurityMessages } from './messages' +import { en } from './locales/en' +import { he } from './locales/he' + +export type AccountSecurityLocale = 'en' | 'he' + +const LOCALES: Record = { en, he } +const RTL_LOCALES = new Set(['he']) + +export interface AccountSecurityI18nContextValue { + locale: AccountSecurityLocale + dir: 'ltr' | 'rtl' + messages: AccountSecurityMessages + /** Interpolate `{name}`-style placeholders. */ + t: (value: string, vars?: Record) => string +} + +const Ctx = createContext(null) + +function mergeDeep( + base: Record, + over: Record +): Record { + const out: Record = { ...base } + for (const key of Object.keys(over)) { + const ov = over[key] + const bv = base[key] + if (ov && typeof ov === 'object' && !Array.isArray(ov) && bv && typeof bv === 'object' && !Array.isArray(bv)) { + out[key] = mergeDeep(bv as Record, ov as Record) + } else if (ov !== undefined && ov !== '') { + out[key] = ov + } + } + return out +} + +function withFallback(locale: AccountSecurityMessages): AccountSecurityMessages { + return mergeDeep( + en as unknown as Record, + locale as unknown as Record + ) as unknown as AccountSecurityMessages +} + +function interpolate(value: string, vars?: Record): string { + if (!vars) return value + return value.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) +} + +export interface AccountSecurityI18nProviderProps { + locale?: AccountSecurityLocale + children: React.ReactNode +} + +export function AccountSecurityI18nProvider({ + locale = 'en', + children, +}: AccountSecurityI18nProviderProps) { + const value = useMemo(() => { + const base = LOCALES[locale] ?? en + return { + locale, + dir: RTL_LOCALES.has(locale) ? 'rtl' : 'ltr', + messages: withFallback(base), + t: interpolate, + } + }, [locale]) + + return {children} +} + +export function useAccountSecurityI18n(): AccountSecurityI18nContextValue { + const ctx = useContext(Ctx) + if (ctx) return ctx + return { locale: 'en', dir: 'ltr', messages: en, t: interpolate } +} diff --git a/packages/account-security-ui/src/i18n/locales/en.ts b/packages/account-security-ui/src/i18n/locales/en.ts new file mode 100644 index 00000000..783289ce --- /dev/null +++ b/packages/account-security-ui/src/i18n/locales/en.ts @@ -0,0 +1,77 @@ +import type { AccountSecurityMessages } from '../messages' + +export const en: AccountSecurityMessages = { + page: { + title: 'Security', + subtitle: 'Manage how you sign in and keep your account safe.', + }, + posture: { + good: 'Your account is well protected', + attention: 'Your account needs attention', + summary: '{password} · {twoFactor} · {devices} · {connected}.', + passwordSet: 'Password set', + passwordMissing: 'No password yet', + twoFactorOn: 'two-factor on', + twoFactorOff: 'two-factor off', + devicesUnknown: 'devices unavailable', + connectedNone: 'no connected accounts', + }, + cards: { + password: { + title: 'Password', + desc: 'Change your password or, if you sign in with a provider only, add one.', + }, + twoFactor: { + title: 'Two-factor auth', + desc: 'Add a second step at sign-in with an authenticator app or a code by SMS.', + }, + sessions: { + title: 'Devices & sessions', + desc: "See where you're signed in and sign out of any device.", + }, + tokens: { + title: 'API tokens', + desc: 'Create and revoke machine tokens for scripts and services.', + }, + connected: { + title: 'Connected accounts', + desc: "Link or unlink sign-in providers. You'll always keep at least one way to sign in.", + }, + }, + badges: { + set: 'Set', + on: 'On', + factors: '{count} factor', + activeDevices: '{count} active', + activeTokens: '{count} active', + unknown: 'Unavailable', + linked: 'linked', + passwordEnabled: 'Password · enabled', + }, + loading: { + label: 'Loading your security settings', + }, + error: { + title: "Couldn't load your security settings", + text: 'Something went wrong on our side. Your account is unaffected — try again.', + retry: 'Try again', + }, + setPassword: { + title: 'Set a password first', + text: 'You sign in with a connected account, so this account has no password yet. Add one to turn on password sign-in and unlock password change.', + action: 'Set a password', + }, + lastMethod: { + title: 'Keep at least one way to sign in', + text: "This is your only sign-in method right now, so it can't be unlinked. Set a password or link another provider first, then unlink it.", + setPassword: 'Set a password', + linkProvider: 'Link another account', + }, + methods: { + heading: 'Ways you sign in', + passwordName: 'Password', + socialName: '{provider}', + remove: 'Remove', + manage: 'Manage', + }, +} diff --git a/packages/account-security-ui/src/i18n/locales/he.ts b/packages/account-security-ui/src/i18n/locales/he.ts new file mode 100644 index 00000000..a6375ec3 --- /dev/null +++ b/packages/account-security-ui/src/i18n/locales/he.ts @@ -0,0 +1,78 @@ +import type { AccountSecurityMessages } from '../messages' + +/** Hebrew (RTL) locale — exercises the RTL mirror path via logical properties. */ +export const he: AccountSecurityMessages = { + page: { + title: 'אבטחה', + subtitle: 'נהל/י כיצד את/ה מתחבר/ת ושמור/י על חשבונך מאובטח.', + }, + posture: { + good: 'החשבון שלך מוגן היטב', + attention: 'החשבון שלך דורש תשומת לב', + summary: '{password} · {twoFactor} · {devices} · {connected}.', + passwordSet: 'סיסמה הוגדרה', + passwordMissing: 'עדיין אין סיסמה', + twoFactorOn: 'אימות דו-שלבי פעיל', + twoFactorOff: 'אימות דו-שלבי כבוי', + devicesUnknown: 'מכשירים אינם זמינים', + connectedNone: 'אין חשבונות מקושרים', + }, + cards: { + password: { + title: 'סיסמה', + desc: 'שנה/י את הסיסמה שלך, או הוסף/י אחת אם את/ה מתחבר/ת רק דרך ספק.', + }, + twoFactor: { + title: 'אימות דו-שלבי', + desc: 'הוסף/י שלב שני בהתחברות עם אפליקציית אימות או קוד ב-SMS.', + }, + sessions: { + title: 'מכשירים והתחברויות', + desc: 'ראה/י היכן את/ה מחובר/ת והתנתק/י מכל מכשיר.', + }, + tokens: { + title: 'אסימוני API', + desc: 'צור/י ובטל/י אסימוני מכונה עבור סקריפטים ושירותים.', + }, + connected: { + title: 'חשבונות מקושרים', + desc: 'קשר/י או נתק/י ספקי התחברות. תמיד תישאר/י עם דרך אחת לפחות להתחבר.', + }, + }, + badges: { + set: 'הוגדרה', + on: 'פעיל', + factors: '{count} גורם', + activeDevices: '{count} פעילים', + activeTokens: '{count} פעילים', + unknown: 'לא זמין', + linked: 'מקושר', + passwordEnabled: 'סיסמה · מופעלת', + }, + loading: { + label: 'טוען את הגדרות האבטחה שלך', + }, + error: { + title: 'לא ניתן היה לטעון את הגדרות האבטחה שלך', + text: 'משהו השתבש אצלנו. החשבון שלך אינו מושפע — נסה/י שוב.', + retry: 'נסה/י שוב', + }, + setPassword: { + title: 'הגדר/י סיסמה תחילה', + text: 'את/ה מתחבר/ת דרך חשבון מקושר, ולכן לחשבון זה אין עדיין סיסמה. הוסף/י אחת כדי להפעיל התחברות עם סיסמה ולאפשר שינוי סיסמה.', + action: 'הגדר/י סיסמה', + }, + lastMethod: { + title: 'שמור/י על דרך אחת לפחות להתחבר', + text: 'זו דרך ההתחברות היחידה שלך כרגע, ולכן לא ניתן לנתק אותה. הגדר/י סיסמה או קשר/י ספק אחר תחילה, ואז נתק/י.', + setPassword: 'הגדר/י סיסמה', + linkProvider: 'קשר/י חשבון אחר', + }, + methods: { + heading: 'הדרכים שבהן את/ה מתחבר/ת', + passwordName: 'סיסמה', + socialName: '{provider}', + remove: 'הסר/י', + manage: 'נהל/י', + }, +} diff --git a/packages/account-security-ui/src/i18n/messages.ts b/packages/account-security-ui/src/i18n/messages.ts new file mode 100644 index 00000000..7036fe29 --- /dev/null +++ b/packages/account-security-ui/src/i18n/messages.ts @@ -0,0 +1,63 @@ +/** Message contract for the account-security hub. `en` is the complete base. */ +export interface AccountSecurityMessages { + page: { + title: string + subtitle: string + } + posture: { + good: string + attention: string + /** summary line, interpolates {password} {twoFactor} {devices} {connected} */ + summary: string + passwordSet: string + passwordMissing: string + twoFactorOn: string + twoFactorOff: string + devicesUnknown: string + connectedNone: string + } + cards: { + password: { title: string; desc: string } + twoFactor: { title: string; desc: string } + sessions: { title: string; desc: string } + tokens: { title: string; desc: string } + connected: { title: string; desc: string } + } + badges: { + set: string + on: string + factors: string // interpolates {count} + activeDevices: string // interpolates {count} + activeTokens: string // interpolates {count} + unknown: string + linked: string + passwordEnabled: string + } + loading: { + label: string + } + error: { + title: string + text: string + retry: string + } + setPassword: { + title: string + text: string + action: string + } + lastMethod: { + title: string + text: string + setPassword: string + linkProvider: string + } + methods: { + heading: string + passwordName: string + /** interpolates {provider} */ + socialName: string + remove: string + manage: string + } +} diff --git a/packages/account-security-ui/src/index.ts b/packages/account-security-ui/src/index.ts new file mode 100644 index 00000000..d2e34fdc --- /dev/null +++ b/packages/account-security-ui/src/index.ts @@ -0,0 +1,51 @@ +// ---- Flow orchestrator ---------------------------------------------------- +export { AccountSecurityHub } from './components/AccountSecurityHub' +export type { AccountSecurityHubProps } from './components/AccountSecurityHub' + +// ---- Presentational hub + components -------------------------------------- +export { SecurityHub } from './components/SecurityHub' +export type { SecurityHubProps, SecurityHubRoutes } from './components/SecurityHub' +export { SecurityPostureSummary, derivePosture } from './components/SecurityPostureSummary' +export type { SecurityPostureSummaryProps } from './components/SecurityPostureSummary' +export { SecurityCard } from './components/SecurityCard' +export type { SecurityCardProps } from './components/SecurityCard' +export { SignInMethodsList } from './components/SignInMethodsList' +export type { SignInMethodsListProps } from './components/SignInMethodsList' +export { SetPasswordBanner } from './components/SetPasswordBanner' +export type { SetPasswordBannerProps } from './components/SetPasswordBanner' +export { ConnectedAccountRow } from './components/ConnectedAccountRow' +export type { ConnectedAccountRowProps } from './components/ConnectedAccountRow' +export { SecurityCardGridSkeleton } from './components/SecurityCardGridSkeleton' +export { LoadErrorRetry } from './components/LoadErrorRetry' +export type { LoadErrorRetryProps } from './components/LoadErrorRetry' +export { providerDisplayName } from './components/providers' + +// ---- API client ----------------------------------------------------------- +export { createAccountSecurityClient } from './api/securityClient' +export { HttpClient, HttpError } from './api/http' +export type { HttpClientOptions } from './api/http' + +// ---- i18n ----------------------------------------------------------------- +export { + AccountSecurityI18nProvider, + useAccountSecurityI18n, +} from './i18n/AccountSecurityI18nProvider' +export type { + AccountSecurityLocale, + AccountSecurityI18nContextValue, + AccountSecurityI18nProviderProps, +} from './i18n/AccountSecurityI18nProvider' +export type { AccountSecurityMessages } from './i18n/messages' + +// ---- Types ---------------------------------------------------------------- +export type { + IdentityConnections, + SocialConnection, + AuthMethods, + SocialProvider, + ErrorBody, + SecurityCardKey, + PostureLevel, + SecurityOverview, + AccountSecurityClient, +} from './types' diff --git a/packages/account-security-ui/src/test/setup.ts b/packages/account-security-ui/src/test/setup.ts new file mode 100644 index 00000000..91277352 --- /dev/null +++ b/packages/account-security-ui/src/test/setup.ts @@ -0,0 +1,5 @@ +/// +import { expect } from 'vitest' +import * as matchers from '@testing-library/jest-dom/matchers' + +expect.extend(matchers) diff --git a/packages/account-security-ui/src/types.ts b/packages/account-security-ui/src/types.ts new file mode 100644 index 00000000..c71eed44 --- /dev/null +++ b/packages/account-security-ui/src/types.ts @@ -0,0 +1,91 @@ +/** + * Contract-bound types for the account-security hub. + * + * `AuthMethods`, `SocialProvider`, and `ErrorBody` are imported from the frozen + * `@fuzefront/security-client` so drift is a compile error. + * + * ⚠️ CONTRACT-CLIENT GAP (for contract-designer / backend-engineer): + * `openapi.yaml` defines `IdentityConnections` + `SocialConnection` + * (GET /v1/security/identity/connections), but the PUBLISHED + * `@fuzefront/security-client` (v0.2.0) exposes NEITHER — its `generated.ts` + * and hand-authored `types.ts` predate those schemas. The client must be + * REGENERATED from the frozen spec and re-published so these types come from + * the client, not this package. Until then they are mirrored here EXACTLY from + * the frozen openapi.yaml (`components.schemas.IdentityConnections` / + * `SocialConnection`). This is the only hand-mirrored surface; remove it once + * the client is regenerated. + */ +import type { AuthMethods as ClientAuthMethods, SocialProvider as ClientSocialProvider } from '@fuzefront/security-client' +import type { components } from '@fuzefront/security-client' + +/** Neutral auth capability descriptor (drives which affordances render). */ +export type AuthMethods = ClientAuthMethods +/** Supported social provider slug (extensible; `google` first). */ +export type SocialProvider = ClientSocialProvider +/** Stable, provider-neutral error body. */ +export type ErrorBody = components['schemas']['ErrorBody'] + +/** + * A linked social sign-in connection. Mirrors `openapi.yaml` + * `components.schemas.SocialConnection` (move to the client once regenerated). + */ +export interface SocialConnection { + provider: SocialProvider + /** Epoch millis when the provider was linked. */ + linkedAt?: number +} + +/** + * The account's sign-in connections: linked social providers + whether a + * password sign-in method exists. Mirrors `openapi.yaml` + * `components.schemas.IdentityConnections` (move to the client once regenerated). + */ +export interface IdentityConnections { + providers: SocialConnection[] + hasPassword: boolean +} + +/** Which hub card a navigation targets. Mirrors the frame `data-route`s. */ +export type SecurityCardKey = + | 'password' + | 'two-factor' + | 'sessions' + | 'tokens' + | 'connected' + +/** Overall posture derived from connections + methods. Never a vendor name. */ +export type PostureLevel = 'good' | 'attention' + +/** + * The data the hub renders, assembled from GET /identity/connections + /methods. + * Fields with no backing endpoint yet render an honest "unknown" state rather + * than fabricated values. + */ +export interface SecurityOverview { + connections: IdentityConnections + methods: AuthMethods + /** + * Active-device count from GET /v1/security/sessions when available. `null` + * means "not loaded here" — the hub renders an honest unknown, never a fake 0. + */ + activeSessions: number | null + /** + * Active API-token count. No backing count endpoint exists on the Security + * contract yet, so this is `null` (honest unknown) until one is added. + */ + activeTokens: number | null +} + +/** Client for the read + fail-closed-mutation surface the hub touches. */ +export interface AccountSecurityClient { + getConnections(): Promise + getMethods(): Promise + /** Optional: active-session count for the devices card. */ + getActiveSessionCount?(): Promise + /** + * Unlink a social provider. Fail-closed: unlinking the account's LAST + * sign-in method rejects with an HttpError(409) rather than succeeding — + * callers (SignInMethodsList) surface the last-sign-in-method guard. + */ + unlinkProvider(provider: SocialConnection['provider']): Promise +} diff --git a/packages/account-security-ui/tsconfig.build.json b/packages/account-security-ui/tsconfig.build.json new file mode 100644 index 00000000..165407ba --- /dev/null +++ b/packages/account-security-ui/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["src/**/*.test.*", "src/**/*.spec.*", "src/test"] +} diff --git a/packages/account-security-ui/tsconfig.json b/packages/account-security-ui/tsconfig.json new file mode 100644 index 00000000..9bc786b1 --- /dev/null +++ b/packages/account-security-ui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "declaration": true, + "outDir": "dist", + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "paths": { + "vitest": ["./node_modules/vitest"], + "vitest/*": ["./node_modules/vitest/*"], + "@vitest/expect": ["./node_modules/@vitest/expect"], + "@vitest/expect/*": ["./node_modules/@vitest/expect/*"] + } + }, + "include": ["src"] +} diff --git a/packages/account-security-ui/vite.config.ts b/packages/account-security-ui/vite.config.ts new file mode 100644 index 00000000..50a65c48 --- /dev/null +++ b/packages/account-security-ui/vite.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vite' +import { fileURLToPath } from 'node:url' +import react from '@vitejs/plugin-react' +import dts from 'vite-plugin-dts' + +const dsRoot = fileURLToPath(new URL('../../design-system', import.meta.url)) +const securityClientSrc = fileURLToPath( + new URL('../security/src/index.ts', import.meta.url) +) + +export default defineConfig({ + plugins: [ + react(), + dts({ insertTypesEntry: true }), + ], + build: { + lib: { + entry: 'src/index.ts', + formats: ['es', 'cjs'], + fileName: (fmt) => (fmt === 'cjs' ? 'index.cjs' : 'index.js'), + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + '@fuzefront/design-system', + /^@fuzefront\/design-system\/.*/, + '@fuzefront/security-client', + ], + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + css: false, + // Resolve workspace packages to SOURCE so unit tests don't depend on the npm + // workspace symlink (which needs GitHub Packages auth). @fuzefront/security-client + // is types-only in this package, but alias it so any value import also resolves. + alias: { + '@fuzefront/design-system': dsRoot + '/index.js', + '@fuzefront/security-client': securityClientSrc, + }, + }, +}) diff --git a/packages/account-security-ui/vitest.config.ts b/packages/account-security-ui/vitest.config.ts new file mode 100644 index 00000000..f112505e --- /dev/null +++ b/packages/account-security-ui/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +// Standalone vitest config (no vite build plugins) so the unit suite runs +// without @vitejs/plugin-react / vite-plugin-dts. esbuild handles the automatic +// JSX transform. Bundling/dts for the library build live in vite.config.ts. +const dsRoot = fileURLToPath(new URL('../../design-system', import.meta.url)) +const securityClientSrc = fileURLToPath(new URL('../security/src/index.ts', import.meta.url)) + +export default defineConfig({ + esbuild: { jsx: 'automatic' }, + resolve: { + alias: { + '@fuzefront/design-system': dsRoot + '/index.js', + '@fuzefront/security-client': securityClientSrc, + }, + dedupe: ['react', 'react-dom', 'react/jsx-runtime'], + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + css: false, + }, +})