From 0761d0dbeeec488d8dd902795dafe36e4d7aa01c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:08:58 +0200 Subject: [PATCH] feat: use design system brand icons for favicon, login, sidebar Replace hand-crafted favicon.svg with official brand assets from @cameleer/design-system v0.1.32: PNG favicons (16/32px) and camel-logo.svg for login dialog and sidebar. Update SecurityConfig public endpoints accordingly. Update documentation for architecture cleanup (PKCE, OidcProviderHelper, role normalization, K8s hardening, Dockerfile credential removal, CI deduplication, sidebar path fix). Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 11 ++++++++--- HOWTO.md | 8 +++++++- .../server/app/security/SecurityConfig.java | 3 ++- docs/SERVER-CAPABILITIES.md | 6 +++--- ui/index.html | 3 ++- ui/package-lock.json | 8 ++++---- ui/package.json | 2 +- ui/public/camel-logo.svg | 3 +++ ui/public/favicon-16.png | Bin 0 -> 869 bytes ui/public/favicon-32.png | Bin 0 -> 2599 bytes ui/src/auth/LoginPage.tsx | 2 +- ui/src/components/LayoutShell.tsx | 2 +- 12 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 ui/public/camel-logo.svg create mode 100644 ui/public/favicon-16.png create mode 100644 ui/public/favicon-32.png diff --git a/CLAUDE.md b/CLAUDE.md index f526d6da..141878ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,8 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class — agents send `environmentId` at registration and in heartbeats. JWT carries `env` claim for environment persistence across token refresh. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`). ClickHouse shared DB with `tenant_id` + `environment` columns, partitioned by `(tenant_id, toYYYYMM(timestamp))`. - Storage: PostgreSQL for RBAC, config, and audit; ClickHouse for all observability data (executions, search, logs, metrics, stats, diagrams). ClickHouse schema migrations in `clickhouse/*.sql`, run idempotently on startup by `ClickHouseSchemaInitializer`. Use `IF NOT EXISTS` for CREATE and ADD PROJECTION. - Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml -- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_CORS_ALLOWED_ORIGINS` (comma-separated) overrides `CAMELEER_UI_ORIGIN` for multi-origin setups (e.g., reverse proxy). UI role gating: Admin sidebar/routes hidden for non-ADMIN; diagram toolbar and route control hidden for VIEWER; Config is a main tab (`/config` all apps, `/config/:appId` single app with detail; sidebar clicks stay on config, route clicks resolve to parent app). Read-only for VIEWER, editable for OPERATOR+. Role helpers: `useIsAdmin()`, `useCanControl()` in `auth-store.ts`. Route guard: `RequireAdmin` in `auth/RequireAdmin.tsx`. -- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. `CAMELEER_OIDC_JWK_SET_URI` overrides JWKS discovery for container networking. `CAMELEER_OIDC_TLS_SKIP_VERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping (case-insensitive): `admin`/`server:admin` → ADMIN, `operator`/`server:operator` → OPERATOR, `viewer`/`server:viewer` → VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login (revocations propagate on next login); group memberships are never touched. Supports ES384, ES256, RS256. +- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_CORS_ALLOWED_ORIGINS` (comma-separated) overrides `CAMELEER_UI_ORIGIN` for multi-origin setups (e.g., reverse proxy). UI role gating: Admin sidebar/routes hidden for non-ADMIN; diagram toolbar and route control hidden for VIEWER; Config is a main tab (`/config` all apps, `/config/:appId` single app with detail; sidebar clicks stay on config, route clicks resolve to parent app). Read-only for VIEWER, editable for OPERATOR+. Role helpers: `useIsAdmin()`, `useCanControl()` in `auth-store.ts`. Route guard: `RequireAdmin` in `auth/RequireAdmin.tsx`. PKCE (S256) enabled on all OIDC authorization requests. +- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. `CAMELEER_OIDC_JWK_SET_URI` overrides JWKS discovery for container networking. `CAMELEER_OIDC_TLS_SKIP_VERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix): `admin`/`server:admin` → ADMIN, `operator`/`server:operator` → OPERATOR, `viewer`/`server:viewer` → VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` + PKCE (S256) for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Logout always redirects to `/login?local` (via OIDC end_session or direct fallback) to prevent SSO re-login loops. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login (revocations propagate on next login); group memberships are never touched. Supports ES384, ES256, RS256. Shared OIDC logic in `OidcProviderHelper` (discovery, JWK source, algorithm set). - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users` - Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s @@ -57,12 +57,17 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Deployment target: k3s at 192.168.50.86, namespace `cameleer` (main), `cam-` (feature branches) - Feature branches: isolated namespace, PG schema; Traefik Ingress at `-api.cameleer.siegeln.net` - Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `clickhouse-credentials` -- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready` +- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready -U "$POSTGRES_USER"` (env var, not hardcoded) +- K8s security: server and database pods run with `securityContext.runAsNonRoot`. UI (nginx) runs without securityContext (needs root for entrypoint setup). +- Docker: server Dockerfile has no default credentials — all DB config comes from env vars at runtime - Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility +- CI: branch slug sanitization extracted to `.gitea/sanitize-branch.sh`, sourced by docker and deploy-feature jobs ## UI Styling - Always use `@cameleer/design-system` CSS variables for colors (`var(--amber)`, `var(--error)`, `var(--success)`, etc.) — never hardcode hex values. This applies to CSS modules, inline styles, and SVG `fill`/`stroke` attributes. SVG presentation attributes resolve `var()` correctly. +- Brand assets: `@cameleer/design-system/assets/` provides `camel-logo.svg` (currentColor), `cameleer3-{16,32,48,192,512}.png`, and `cameleer3-logo.png`. Copied to `ui/public/` for use as favicon (`favicon-16.png`, `favicon-32.png`) and logo (`camel-logo.svg` — login dialog 36px, sidebar 28x24px). +- Sidebar generates `/exchanges/` paths directly (no legacy `/apps/` redirects). basePath is centralized in `ui/src/config.ts`; router.tsx imports it instead of re-reading `` tag. - Global user preferences (environment selection) use Zustand stores with localStorage persistence — never URL search params. URL params are for page-specific state only (e.g. `?text=` search query). Switching environment resets all filters and remounts pages. ## Disabled Skills diff --git a/HOWTO.md b/HOWTO.md index 0acf7b7f..3ffae61d 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -39,9 +39,15 @@ PostgreSQL credentials: `cameleer` / `cameleer_dev`, database `cameleer3`. ```bash mvn clean package -DskipTests -CAMELEER_AUTH_TOKEN=my-secret-token java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar +SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/cameleer3 \ +SPRING_DATASOURCE_USERNAME=cameleer \ +SPRING_DATASOURCE_PASSWORD=cameleer_dev \ +CAMELEER_AUTH_TOKEN=my-secret-token \ +java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar ``` +> **Note:** The Docker image no longer includes default database credentials. When running via `docker run`, pass `-e SPRING_DATASOURCE_URL=...` etc. The docker-compose setup provides these automatically. + The server starts on **port 8081**. The `CAMELEER_AUTH_TOKEN` environment variable is **required** — the server fails fast on startup if it is not set. For token rotation without downtime, set `CAMELEER_AUTH_TOKEN_PREVIOUS` to the old token while rolling out the new one. The server accepts both during the overlap window. diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index fe27af21..ccc74973 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -94,7 +94,8 @@ public class SecurityConfig { "/", "/index.html", "/config.js", - "/favicon.svg", + "/favicon-*.png", + "/camel-logo.svg", "/assets/**" ).permitAll() diff --git a/docs/SERVER-CAPABILITIES.md b/docs/SERVER-CAPABILITIES.md index 90eabe9d..4800e710 100644 --- a/docs/SERVER-CAPABILITIES.md +++ b/docs/SERVER-CAPABILITIES.md @@ -271,11 +271,11 @@ Server derives an Ed25519 keypair deterministically from the JWT secret. Public ### OIDC Integration -Configured via admin API (`/api/v1/admin/oidc`) or admin UI. Supports any OpenID Connect provider. Features: configurable user ID claim (`userIdClaim`, default `sub` — e.g., `email`, `preferred_username`), role claim extraction (supports nested paths like `realm_access.roles`), auto-signup (auto-provisions new users on first OIDC login), configurable display name claim, constant-time token rotation via dual bootstrap tokens. Supports ES384 (Logto default), ES256, and RS256 for id_token validation. System roles are synced on every OIDC login (not just first) — revoking a scope in the provider takes effect on next login. Group memberships (manually assigned) are never touched by the sync. +Configured via admin API (`/api/v1/admin/oidc`) or admin UI. Supports any OpenID Connect provider. Features: configurable user ID claim (`userIdClaim`, default `sub` — e.g., `email`, `preferred_username`), role claim extraction (supports nested paths like `realm_access.roles`), auto-signup (auto-provisions new users on first OIDC login), configurable display name claim, constant-time token rotation via dual bootstrap tokens, PKCE (S256) on all authorization requests. Supports ES384 (Logto default), ES256, and RS256 for id_token validation. System roles are synced on every OIDC login (not just first) — revoking a scope in the provider takes effect on next login. Group memberships (manually assigned) are never touched by the sync. Role normalization via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix). Shared OIDC infrastructure (discovery, JWK source, algorithm set) centralized in `OidcProviderHelper`. ### SSO Auto-Redirect -When OIDC is configured and enabled, the login page automatically redirects to the OIDC provider with `prompt=none` for silent SSO. If the user has an active provider session, they are signed in without seeing a login form. If `consent_required` is returned (first login, scopes not yet granted), the flow retries without `prompt=none` so the user can grant consent once. If `login_required` (no provider session), falls back to the login form. Bypass auto-redirect with `/login?local`. +When OIDC is configured and enabled, the login page automatically redirects to the OIDC provider with `prompt=none` and PKCE (S256) for silent SSO. If the user has an active provider session, they are signed in without seeing a login form. If `consent_required` is returned (first login, scopes not yet granted), the flow retries without `prompt=none` so the user can grant consent once. If `login_required` (no provider session), falls back to the login form. Bypass auto-redirect with `/login?local`. Logout always redirects to `/login?local` — either via the OIDC `end_session_endpoint` (with `post_logout_redirect_uri`) or as a direct fallback — preventing SSO re-login loops. ### OIDC Resource Server @@ -392,7 +392,7 @@ Stats tables are fed by Materialized Views from base tables. Query with `-Merge( ### Container Image -Multi-stage Docker build: Maven 3.9 + JDK 17 (build) → JRE 17 (runtime). Port 8081. +Multi-stage Docker build: Maven 3.9 + JDK 17 (build) → JRE 17 (runtime). Port 8081. No default credentials baked in — all database config comes from env vars at runtime. Registry: `gitea.siegeln.net/cameleer/cameleer3-server` diff --git a/ui/index.html b/ui/index.html index 70980c1c..639f5a45 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,7 +2,8 @@ - + + Cameleer3 diff --git a/ui/package-lock.json b/ui/package-lock.json index b9dc6219..fc4e67b2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,7 +8,7 @@ "name": "ui", "version": "0.0.0", "dependencies": { - "@cameleer/design-system": "^0.1.31", + "@cameleer/design-system": "^0.1.32", "@tanstack/react-query": "^5.90.21", "lucide-react": "^1.7.0", "openapi-fetch": "^0.17.0", @@ -278,9 +278,9 @@ } }, "node_modules/@cameleer/design-system": { - "version": "0.1.31", - "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.31/design-system-0.1.31.tgz", - "integrity": "sha512-yfuMQdLB3RS6loT31YozKCyBUnctxlLPZjpzPzD7UQZhCFU4+ScibMzbPeLFE/MyMkfrpk/j9v8pJCHW7l64TQ==", + "version": "0.1.32", + "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.32/design-system-0.1.32.tgz", + "integrity": "sha512-ocbTUTUZEpDAprsYD3Ao2ZVZXGSbFg3amL8FS85FoJKdJ3xKZO/kpkTKPl+qmsbh/E030QwdulBR8YnQYlHDBg==", "dependencies": { "lucide-react": "^1.7.0", "react": "^19.0.0", diff --git a/ui/package.json b/ui/package.json index a43d361b..913bd90b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,7 @@ "generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts" }, "dependencies": { - "@cameleer/design-system": "^0.1.31", + "@cameleer/design-system": "^0.1.32", "@tanstack/react-query": "^5.90.21", "lucide-react": "^1.7.0", "openapi-fetch": "^0.17.0", diff --git a/ui/public/camel-logo.svg b/ui/public/camel-logo.svg new file mode 100644 index 00000000..b83d1b07 --- /dev/null +++ b/ui/public/camel-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/public/favicon-16.png b/ui/public/favicon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..4cfc9ed5486a372a954d894596a6dbc2bcd64138 GIT binary patch literal 869 zcmV-r1DgDaP)pI7fD1xRCt_SliO=tRTRe0pWu_eDfl7?Vi6xi@kJB^)u)0;!KdPr z1fg1?_#m;>)Rx9*b1~6oQf(WXl9;sBq)F3cn#)|0%snSFlMH6AXU<%XN#@d-&e`WD z=bWjE3-)I1&9}b2*4p2agkfM90wHt^KN`OLmoV?FL-akrFo}yNYSvbQmY;4i%93Xz-COFLA{=GvvIhEW4QlUpCAd8k-eAzEz$VF&N0+@#mOqggFj z=ZCd2NzWjez%?r~>w!{?3syd6(2mkCpyo)AdIvkqHX%S)0JHX&M!k z{k>(h-TO#|*j?)jvynTCNlZv?Q|gGUaTPT0TH2K1(7n zgO>KA3!Ry7-@!fj2K$8__E!68H7fQuCOr-!)1QzJTqUkw>Wdc%jl4zLJ&Y><@vjb=b&b`54qPM0kndfrG+$8k700000NkvXXu0mjf3Ob)e literal 0 HcmV?d00001 diff --git a/ui/public/favicon-32.png b/ui/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..35257dd13d76444df0a1aa5b37bfcb465324aea3 GIT binary patch literal 2599 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
- + cameleer3

{subtitle}

diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 199b2b20..0bcbe8eb 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -625,7 +625,7 @@ function LayoutContent() { // --- Render ------------------------------------------------------- const camelLogo = ( - + ); const sidebarElement = (