diff --git a/CLAUDE.md b/CLAUDE.md
index 0a0a51a6..40bc92f3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
Paperless-AI **next** — a community fork of clusterzx/paperless-ai. A Node.js/Express app that uses
-LLMs (OpenAI, Ollama, Azure, or any OpenAI-compatible "custom" endpoint) plus optional OCR to auto-tag,
+LLMs (OpenAI, Ollama, Azure, Anthropic, or any OpenAI-compatible "custom" endpoint) plus optional OCR to auto-tag,
title, classify, and extract metadata for documents in a Paperless-ngx instance. Server-rendered EJS UI,
no frontend framework. Single SQLite database (better-sqlite3, WAL mode).
@@ -54,12 +54,18 @@ the `services/` singletons.
`class Foo { ... }; module.exports = new Foo();`. Import and call directly; do not `new` them.
**AI provider abstraction:** `services/aiServiceFactory.js` returns the right service based on
-`config.aiProvider` (`openai` | `ollama` | `custom` | `azure`). Every provider service implements
+`config.aiProvider` (`openai` | `ollama` | `custom` | `azure` | `anthropic`). Every provider service implements
`analyzeDocument(content, doc, existingTags, correspondents)`. To add a provider: add a service singleton,
wire it into the factory switch, and add its config block in `config/config.js`.
-**Token handling:** `services/serviceUtils.js` — `calculateTokens()` (tiktoken for OpenAI, ÷4 char
-estimate otherwise) and `truncateToTokenLimit()`. Also holds AI-error → OCR-fallback classification
+**Anthropic** is not OpenAI-compatible and has its own service (`services/anthropicService.js`). Per-model
+request rules (thinking, effort, structured output, cache minimums), the client factory, usage mapping and
+the truncation/refusal check live in `services/anthropicSupport.js` and are shared with Claude OCR; never
+send `temperature` to it or read `content[0]`. Its analysis prompt and JSON schema come from
+`services/analysisPrompt.js`, which the other providers do not use yet.
+
+**Token handling:** `services/serviceUtils.js` — `calculateTokens()` (tiktoken for OpenAI, ÷3 char
+estimate for `claude-*`, ÷4 otherwise) and `truncateToTokenLimit()`. Also holds AI-error → OCR-fallback classification
helpers used by the scan loop.
**Prompt restrictions:** `services/restrictionPromptService.js` replaces placeholders like
@@ -69,7 +75,7 @@ in prompts. The base prompts live in `config/config.js` (`specialPromptPreDefine
normalize them with `toNameList()` from `services/serviceUtils.js` instead of joining them directly.
**OCR:** `services/mistralOcrService.js` — provider can be `mistral`, `ollama` (native `/api/chat`
-vision), or OpenAI-compatible `/v1`. OCR is a fallback path when AI analysis fails on low-text docs.
+vision), OpenAI-compatible `/v1`, or `anthropic` (Claude reads PDFs and images natively). OCR is a fallback path when AI analysis fails on low-text docs.
**Database:** `models/document.js` — better-sqlite3 at `data/documents.db`, WAL mode, prepared
statements throughout. Key tables: `processed_documents`, `history_documents` (server-side paginated
diff --git a/OPENAPI/openapi.json b/OPENAPI/openapi.json
index ec2c115c..4205f5f7 100644
--- a/OPENAPI/openapi.json
+++ b/OPENAPI/openapi.json
@@ -471,14 +471,24 @@
},
"message": {
"type": "string",
- "description": "Additional status information (for non-healthy states)",
+ "description": "Additional status information (for non-healthy states). Authenticated callers only.",
"example": "Document scan failed 3 time(s) in a row: connect ECONNREFUSED 172.18.0.2:8000"
},
"scanner": {
- "$ref": "#/components/schemas/ScannerHealth"
+ "description": "Authenticated callers only.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ScannerHealth"
+ }
+ ]
},
"paperless": {
- "$ref": "#/components/schemas/PaperlessHealth"
+ "description": "Authenticated callers only.",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/PaperlessHealth"
+ }
+ ]
}
}
},
@@ -4609,6 +4619,10 @@
"nullable": true,
"description": "Recommended default; absent for providers without model classification"
},
+ "live": {
+ "type": "boolean",
+ "description": "anthropic only — false when the live list failed and the suggested Claude models are returned instead"
+ },
"resolvedApiUrl": {
"type": "string"
},
@@ -4697,17 +4711,21 @@
"openai",
"ollama",
"custom",
- "azure"
+ "azure",
+ "anthropic"
]
},
"apiUrl": {
- "type": "string"
+ "type": "string",
+ "description": "Not needed for anthropic unless a proxy sits in front of api.anthropic.com"
},
"token": {
- "type": "string"
+ "type": "string",
+ "description": "Omit or leave empty to use the stored key of the selected provider"
},
"model": {
- "type": "string"
+ "type": "string",
+ "description": "For anthropic, checked with a models lookup that spends no tokens"
},
"setupValidationTimeoutMs": {
"type": "integer"
@@ -4727,6 +4745,217 @@
}
}
},
+ "/api/settings/ocr/test": {
+ "post": {
+ "summary": "Test OCR provider connectivity from the settings page",
+ "description": "Runs one OCR request on a generated test image. For `anthropic` the\nkey is the OCR key when one is given or stored for Claude OCR, and\notherwise the Anthropic AI provider's key (`anthropicApiKey`, then\nthe stored ANTHROPIC_API_KEY).\n",
+ "tags": [
+ "Settings"
+ ],
+ "security": [
+ {
+ "BearerAuth": []
+ },
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "string",
+ "enum": [
+ "yes",
+ "no"
+ ]
+ },
+ "provider": {
+ "type": "string",
+ "enum": [
+ "mistral",
+ "custom",
+ "ollama",
+ "anthropic"
+ ]
+ },
+ "apiUrl": {
+ "type": "string",
+ "description": "Ignored for mistral. For anthropic an optional proxy URL; empty uses the Anthropic AI provider's URL, then api.anthropic.com"
+ },
+ "apiKey": {
+ "type": "string",
+ "description": "Omit or leave empty to use the stored key"
+ },
+ "model": {
+ "type": "string",
+ "description": "For anthropic, a Claude model; anything else falls back to the AI model"
+ },
+ "setupOcrValidationTimeoutMs": {
+ "type": "integer"
+ },
+ "anthropicApiKey": {
+ "type": "string",
+ "description": "Anthropic AI key typed but not saved yet; Claude OCR falls back to it"
+ },
+ "anthropicApiUrl": {
+ "type": "string",
+ "description": "Anthropic AI proxy URL typed but not saved yet"
+ },
+ "anthropicModel": {
+ "type": "string",
+ "description": "Anthropic AI model typed but not saved yet"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OCR connectivity result returned"
+ },
+ "401": {
+ "description": "Unauthorized"
+ }
+ }
+ }
+ },
+ "/api/settings/oidc/test": {
+ "post": {
+ "summary": "Test a single sign-on provider from the settings page",
+ "description": "Reads the provider's discovery document, picks the token endpoint auth\nmethod the login would use, and checks the client credentials by\nredeeming an authorization code that cannot exist: the provider\nanswers invalid_client for wrong credentials and invalid_grant for\nright ones. Nobody is signed in and nothing is saved.\n\nAn empty `clientSecret` uses the stored OIDC_CLIENT_SECRET, but only\nwhen the issuer has the same origin (scheme, host, port) as the\nconfigured OIDC_ISSUER_URL; for any other issuer the test runs\nwithout a secret. Private network addresses are allowed, since\nidentity providers usually run next to the application.\n",
+ "tags": [
+ "Settings"
+ ],
+ "security": [
+ {
+ "BearerAuth": []
+ },
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "issuerUrl",
+ "clientId"
+ ],
+ "properties": {
+ "issuerUrl": {
+ "type": "string",
+ "description": "Issuer URL (http or https); a pasted discovery URL is accepted",
+ "example": "https://auth.example.com/application/o/zettelrobbe/"
+ },
+ "clientId": {
+ "type": "string",
+ "example": "zettelrobbe"
+ },
+ "clientSecret": {
+ "type": "string",
+ "description": "Omit or leave empty to use the stored secret (same issuer origin only)",
+ "example": ""
+ },
+ "tokenAuthMethod": {
+ "type": "string",
+ "description": "Empty picks the method automatically",
+ "enum": [
+ "",
+ "client_secret_basic",
+ "client_secret_post",
+ "none"
+ ],
+ "example": ""
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Test result. success is false (still HTTP 200) when the provider\ncould not be reached, sent no usable discovery document, or refused\nthe client credentials; error then says which.\n",
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "example": true
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "issuer": {
+ "type": "string",
+ "description": "The issuer the provider advertises",
+ "example": "https://auth.example.com/application/o/zettelrobbe/"
+ },
+ "tokenAuthMethods": {
+ "type": "array",
+ "description": "Token endpoint auth methods the provider advertises (empty when it lists none)",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "client_secret_basic",
+ "client_secret_post"
+ ]
+ },
+ "chosenAuthMethod": {
+ "type": "string",
+ "description": "The method a login would use",
+ "example": "client_secret_basic"
+ },
+ "pkce": {
+ "type": "boolean",
+ "description": "Whether the provider advertises PKCE with S256",
+ "example": true
+ }
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "The provider answered and accepted the client."
+ }
+ }
+ },
+ {
+ "$ref": "#/components/schemas/Error"
+ }
+ ]
+ }
+ }
+ }
+ },
+ "302": {
+ "description": "Not signed in; redirected to /login like every other route behind the authentication guard"
+ },
+ "400": {
+ "description": "Invalid input (issuer not an http(s) URL, empty client ID, unknown auth method, or a method that needs a secret without one)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/settings/ocr/models": {
"post": {
"summary": "Discover available OCR models from the settings page",
@@ -4754,7 +4983,8 @@
"enum": [
"mistral",
"custom",
- "ollama"
+ "ollama",
+ "anthropic"
]
},
"apiUrl": {
@@ -4762,10 +4992,22 @@
},
"apiKey": {
"type": "string",
- "description": "Omit or leave empty to use the stored OCR_API_KEY / MISTRAL_API_KEY"
+ "description": "Omit or leave empty to use the stored OCR_API_KEY / MISTRAL_API_KEY (for anthropic, the stored Claude OCR key, then the Anthropic AI key)"
},
"setupOcrValidationTimeoutMs": {
"type": "integer"
+ },
+ "anthropicApiKey": {
+ "type": "string",
+ "description": "Anthropic AI key typed but not saved yet; used by anthropic when no OCR key is set"
+ },
+ "anthropicApiUrl": {
+ "type": "string",
+ "description": "Anthropic AI proxy URL typed but not saved yet"
+ },
+ "anthropicModel": {
+ "type": "string",
+ "description": "Anthropic AI model; anthropic returns it as suggestedModel"
}
}
}
@@ -4802,6 +5044,10 @@
"nullable": true,
"description": "Recommended default; absent for providers without model classification"
},
+ "live": {
+ "type": "boolean",
+ "description": "anthropic only — false when the live list failed and the suggested Claude models are returned instead"
+ },
"resolvedApiUrl": {
"type": "string"
},
@@ -5642,7 +5888,8 @@
"openai",
"ollama",
"custom",
- "azure"
+ "azure",
+ "anthropic"
],
"example": "openai"
},
@@ -5686,6 +5933,58 @@
"description": "Model name for custom LLM provider",
"example": "custom-model"
},
+ "anthropicApiKey": {
+ "type": "string",
+ "description": "API key for Anthropic (required when aiProvider is 'anthropic' and none is stored; leave empty to keep the configured value). Saved only with aiProvider 'anthropic'; the anthropic* fields below likewise.",
+ "example": "sk-ant-api03-abc123"
+ },
+ "anthropicModel": {
+ "type": "string",
+ "description": "Claude model for analysis; empty keeps the configured one",
+ "example": "claude-sonnet-5-5"
+ },
+ "anthropicEffort": {
+ "type": "string",
+ "description": "Effort level; empty leaves it to the model. Rejected with 400 when it is not one of the listed levels.",
+ "enum": [
+ "",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ],
+ "example": ""
+ },
+ "anthropicMaxTokens": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Completion cap per request, thinking included. Rejected with 400 when it is not a positive whole number; empty keeps the configured value.",
+ "example": 16000
+ },
+ "anthropicStructuredOutput": {
+ "type": "string",
+ "description": "Constrain the analysis answer to a JSON schema (yes/no)",
+ "enum": [
+ "yes",
+ "no"
+ ],
+ "example": "yes"
+ },
+ "anthropicPromptCaching": {
+ "type": "string",
+ "description": "Mark the system prompt cacheable (yes/no)",
+ "enum": [
+ "yes",
+ "no"
+ ],
+ "example": "yes"
+ },
+ "anthropicApiUrl": {
+ "type": "string",
+ "description": "Only for a proxy; empty uses api.anthropic.com",
+ "example": ""
+ },
"scanInterval": {
"type": "number",
"description": "Interval in minutes for scanning new documents",
@@ -5922,6 +6221,17 @@
"description": "How many entries the vocabulary proposed from the tag names should aim at, types and topics together (5-100, default 25). Out-of-range values are clamped, a non-numeric value keeps the current setting.",
"example": 25
},
+ "ocrProvider": {
+ "type": "string",
+ "description": "OCR fallback provider. For anthropic (Claude OCR) an empty ocrApiKey uses the Anthropic AI key, and ocrApiUrl is an optional proxy URL; a key or URL stored for another provider is cleared when switching to it.",
+ "enum": [
+ "mistral",
+ "custom",
+ "ollama",
+ "anthropic"
+ ],
+ "example": "mistral"
+ },
"ocrAutoProcessEnabled": {
"type": "string",
"description": "Process queued OCR documents automatically (yes/no)",
@@ -5950,6 +6260,91 @@
"YYYY-MM-DD"
],
"example": "DD.MM.YYYY"
+ },
+ "oidcEnabled": {
+ "type": "string",
+ "description": "Single sign-on switch (OIDC_ENABLED). Every oidc* field is optional; one the body\nleaves out keeps the stored value. When single sign-on ends up switched on, the save\nis refused with 400 unless the issuer URL is an http(s) URL, the client ID is set,\nthe admin e-mail is valid, the redirect URI is empty or an absolute http(s) URL, and\nthe auth method, button icon and colours are valid. Values injected by the container\nare never overwritten.\n",
+ "enum": [
+ "yes",
+ "no"
+ ],
+ "example": "no"
+ },
+ "oidcIssuerUrl": {
+ "type": "string",
+ "description": "Issuer URL of the OpenID Connect provider (OIDC_ISSUER_URL); a pasted discovery URL is accepted",
+ "example": "https://auth.example.com/application/o/zettelrobbe/"
+ },
+ "oidcClientId": {
+ "type": "string",
+ "description": "Client ID registered at the provider (OIDC_CLIENT_ID)",
+ "example": "zettelrobbe"
+ },
+ "oidcClientSecret": {
+ "type": "string",
+ "description": "Client secret (OIDC_CLIENT_SECRET); leave empty to keep the stored one",
+ "example": ""
+ },
+ "oidcAdminEmail": {
+ "type": "string",
+ "description": "E-mail address of the identity that signs in as the administrator (OIDC_ADMIN_EMAIL)",
+ "example": "admin@example.com"
+ },
+ "oidcRedirectUri": {
+ "type": "string",
+ "description": "Callback URL registered at the provider (OIDC_REDIRECT_URI); empty derives it from the request",
+ "example": ""
+ },
+ "oidcScopes": {
+ "type": "string",
+ "description": "Space-separated scopes (OIDC_SCOPES); openid is always added",
+ "example": "openid profile email"
+ },
+ "oidcEmailClaim": {
+ "type": "string",
+ "description": "Claim that carries the e-mail address (OIDC_EMAIL_CLAIM)",
+ "example": "email"
+ },
+ "oidcAllowUnverifiedEmail": {
+ "type": "string",
+ "description": "Accept an e-mail address the provider marks unverified (OIDC_ALLOW_UNVERIFIED_EMAIL)",
+ "enum": [
+ "yes",
+ "no"
+ ],
+ "example": "no"
+ },
+ "oidcTokenAuthMethod": {
+ "type": "string",
+ "description": "Token endpoint auth method (OIDC_TOKEN_AUTH_METHOD); empty picks it automatically",
+ "enum": [
+ "",
+ "client_secret_basic",
+ "client_secret_post",
+ "none"
+ ],
+ "example": ""
+ },
+ "oidcButtonText": {
+ "type": "string",
+ "maxLength": 60,
+ "description": "Label of the login button (OIDC_BUTTON_TEXT); longer text is cut to 60 characters",
+ "example": "Sign in with Authentik"
+ },
+ "oidcButtonIcon": {
+ "type": "string",
+ "description": "Login button icon (OIDC_BUTTON_ICON); empty for the built-in icon, \"none\", an https URL or a path on this server",
+ "example": ""
+ },
+ "oidcButtonColor": {
+ "type": "string",
+ "description": "Login button background (OIDC_BUTTON_COLOR); hex, colour name, rgb() or hsl()",
+ "example": "#fd4b2d"
+ },
+ "oidcButtonTextColor": {
+ "type": "string",
+ "description": "Login button text colour (OIDC_BUTTON_TEXT_COLOR)",
+ "example": "#ffffff"
}
}
}
@@ -6567,7 +6962,7 @@
"/health": {
"get": {
"summary": "System health check endpoint",
- "description": "Reports database connectivity **and** whether the document scanner is\nactually able to work. Used by the Docker healthcheck and monitoring.\n\n`status` is `healthy` when the database is reachable and the scanner is\noperational, `degraded` when automatic processing is enabled but the\nscan loop is not armed or has failed `failureThreshold` runs in a row\n(for example because Paperless-ngx is unreachable), and `database_error`\nwhen the local database cannot be queried.\n\nA `degraded` state answers with HTTP 503 unless `HEALTHCHECK_STRICT=no`\nis configured, in which case the details are reported with HTTP 200.\nWhen automatic processing is switched off via\n`DISABLE_AUTOMATIC_PROCESSING=yes`, a missing scan loop is expected and\nnever reported as degraded.\n",
+ "description": "Reports database connectivity **and** whether the document scanner is\nactually able to work. Used by the Docker healthcheck and monitoring.\n\n`status` is `healthy` when the database is reachable and the scanner is\noperational, `degraded` when automatic processing is enabled but the\nscan loop is not armed or has failed `failureThreshold` runs in a row\n(for example because Paperless-ngx is unreachable), and `database_error`\nwhen the local database cannot be queried.\n\nA `degraded` state answers with HTTP 503 unless `HEALTHCHECK_STRICT=no`\nis configured, in which case the details are reported with HTTP 200.\nWhen automatic processing is switched off via\n`DISABLE_AUTOMATIC_PROCESSING=yes`, a missing scan loop is expected and\nnever reported as degraded.\n\nWithout authentication the response carries only `status` and\n`database`, with the same HTTP status codes. `message`, `scanner` and\n`paperless` hold raw error messages with internal host names and\nports, so they are returned only with a session (cookie or bearer\ntoken) or the `x-api-key` header.\n",
"tags": [
"System"
],
@@ -10254,6 +10649,75 @@
}
}
}
+ },
+ "/auth/oidc/login": {
+ "get": {
+ "summary": "Start single sign-on with the configured OpenID Connect provider",
+ "description": "Redirects the browser to the identity provider's authorization endpoint\n(authorization code flow with PKCE, state and nonce). Only available when\nOIDC_ENABLED=yes and OIDC_ISSUER_URL, OIDC_CLIENT_ID and OIDC_ADMIN_EMAIL\nare set.\n",
+ "tags": [
+ "Authentication"
+ ],
+ "responses": {
+ "302": {
+ "description": "Redirect to the identity provider"
+ },
+ "404": {
+ "description": "Single sign-on is not enabled"
+ },
+ "502": {
+ "description": "The identity provider could not be reached; the login page is rendered with an error"
+ }
+ }
+ }
+ },
+ "/auth/oidc/callback": {
+ "get": {
+ "summary": "Complete single sign-on",
+ "description": "Redirect target registered with the identity provider. Exchanges the\nauthorization code, validates the ID token and signs in as the\nadministrator account when the identity's e-mail address equals\nOIDC_ADMIN_EMAIL. Sets the session cookie.\n",
+ "tags": [
+ "Authentication"
+ ],
+ "parameters": [
+ {
+ "in": "query",
+ "name": "code",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Authorization code issued by the provider"
+ },
+ {
+ "in": "query",
+ "name": "state",
+ "schema": {
+ "type": "string"
+ },
+ "description": "State value echoed back by the provider"
+ }
+ ],
+ "responses": {
+ "302": {
+ "description": "Signed in, redirected to the dashboard",
+ "headers": {
+ "Set-Cookie": {
+ "schema": {
+ "type": "string",
+ "description": "HTTP-only cookie containing the session JWT"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Missing or expired sign-in transaction, or the provider reported an error"
+ },
+ "403": {
+ "description": "The identity's e-mail address does not match OIDC_ADMIN_EMAIL"
+ },
+ "404": {
+ "description": "Single sign-on is not enabled"
+ }
+ }
+ }
}
},
"tags": [
diff --git a/README.md b/README.md
index 6bc639b0..941db9fd 100644
--- a/README.md
+++ b/README.md
@@ -140,6 +140,23 @@ Then open [http://localhost:3000](http://localhost:3000) to complete setup.
- The Mistral OCR provider processes PDFs natively and ignores the `OCR_PDF_RENDER_*` settings
- The OCR queue can be worked through automatically instead of pressing **Process All Pending**: enable `OCR_AUTO_PROCESS_ENABLED` (default `no`) and configure `OCR_AUTO_PROCESS_INTERVAL` (cron, default `*/15 * * * *`), `OCR_AUTO_PROCESS_BATCH_SIZE` (documents per run, default `10`) and `OCR_AUTO_ANALYZE` (run AI analysis right after OCR, default `yes`). Runs are skipped while a document scan is active or while Paperless-ngx is unreachable, so queued documents are never marked as failed because of an outage
+### Single Sign-On (OIDC)
+
+Sign in with any OpenID Connect provider (Authentik, Authelia, Keycloak, Kanidm, Pocket ID, Zitadel, Entra ID, Google, ...) through a button on the login page. Zettelrobbe has one administrator account, and the identity whose e-mail address equals `OIDC_ADMIN_EMAIL` signs in as that account. Password login keeps working next to it.
+
+```yaml
+environment:
+ OIDC_ENABLED: 'yes'
+ OIDC_ISSUER_URL: https://auth.example.com/application/o/zettelrobbe/
+ OIDC_CLIENT_ID: zettelrobbe
+ OIDC_CLIENT_SECRET: ${ZETTELROBBE_OIDC_CLIENT_SECRET}
+ OIDC_REDIRECT_URI: https://zettelrobbe.example.com/auth/oidc/callback
+ OIDC_ADMIN_EMAIL: you@example.com # required
+ OIDC_BUTTON_TEXT: Sign in with Authentik # optional, also OIDC_BUTTON_ICON / _COLOR / _TEXT_COLOR
+```
+
+See [docs/sso-oidc.md](docs/sso-oidc.md) for all options, provider examples, button customization, security notes and troubleshooting.
+
### Container Images
| Image Tag | Size |
diff --git a/config/ai-provider-presets.json b/config/ai-provider-presets.json
index cd427dc0..3dae96b6 100644
--- a/config/ai-provider-presets.json
+++ b/config/ai-provider-presets.json
@@ -8,6 +8,14 @@
"model": "gpt-4o-mini",
"tokenPlaceholder": "sk-..."
},
+ {
+ "id": "anthropic-default",
+ "label": "Anthropic (Claude)",
+ "provider": "anthropic",
+ "apiUrl": "",
+ "model": "claude-haiku-5-5",
+ "tokenPlaceholder": "sk-ant-..."
+ },
{
"id": "lmstudio-local",
"label": "LM Studio (OpenAI compatible)",
diff --git a/config/changelog.js b/config/changelog.js
index 3f6fe20c..0bf3de73 100644
--- a/config/changelog.js
+++ b/config/changelog.js
@@ -143,6 +143,20 @@ const RELEASES = [
'New: Settings sections for Duplicates and Simplify tags',
],
},
+ {
+ version: 'v2026.10.01',
+ entries: [
+ 'New: Anthropic (Claude) as AI provider. Settings and the setup wizard take an API key and list the Claude models it can use; Claude Haiku 5.5 is the default. The analysis comes back as structured output, and prompt caching keeps the repeated instructions cheap',
+ 'New: Claude as OCR provider. It reads PDFs and images directly and uses the API key and model of the AI provider unless OCR has its own',
+ 'Fix: Saving the settings no longer signs everybody out. The restart after a save created a new sign-in key, so every open session ended, and a page left open only showed vague errors such as "Model discovery failed". A session that ends now sends the page to the login screen',
+ 'Fix: The manual tagging page uses the current design again. Its buttons, cards, document preview and tag chips were still built on styles that no longer exist',
+ 'New: Single sign-on with any OpenID Connect provider (Authentik, Keycloak, Entra ID and others). Contributed by @krauseerl in #372. E-mail addresses the provider marks as unverified are refused unless OIDC_ALLOW_UNVERIFIED_EMAIL=yes',
+ 'New: Single sign-on can be set up on the settings page, including a "Test connection" button. Values set by the container stay locked there',
+ 'Improvement: The settings page shows one topic at a time, grouped in a sidebar, with a search field and a save bar that counts unsaved changes across all topics',
+ 'Improvement: The Claude model is picked from a dropdown filled by "Fetch Models", like every other model',
+ 'Security: The login no longer reveals whether a username exists, a two-factor code is accepted only once, and the health endpoint keeps scanner and Paperless-ngx details behind authentication. Contributed by @krauseerl',
+ ],
+ },
];
const latestRelease = RELEASES[RELEASES.length - 1];
diff --git a/config/config.js b/config/config.js
index 61eeb338..96c412f9 100644
--- a/config/config.js
+++ b/config/config.js
@@ -368,6 +368,32 @@ const getCookieSecureMode = () => {
return 'auto';
};
+/* Single sign-on through OpenID Connect. Read on every call rather than
+ snapshotted at load, like the other auth getters above, so a runtime
+ override or a test can change it without reloading the module. Off unless
+ OIDC_ENABLED is set; password login keeps working either way. */
+const getOidcConfig = () => ({
+ enabled: parseEnvBoolean(process.env.OIDC_ENABLED, 'no') === 'yes',
+ issuerUrl: String(process.env.OIDC_ISSUER_URL || '').trim(),
+ clientId: String(process.env.OIDC_CLIENT_ID || '').trim(),
+ clientSecret: String(process.env.OIDC_CLIENT_SECRET || ''),
+ // Required: the identity whose e-mail matches signs in as the admin.
+ adminEmail: String(process.env.OIDC_ADMIN_EMAIL || '').trim(),
+ redirectUri: String(process.env.OIDC_REDIRECT_URI || '').trim(),
+ scopes: String(process.env.OIDC_SCOPES || 'openid profile email').trim(),
+ emailClaim: String(process.env.OIDC_EMAIL_CLAIM || 'email').trim() || 'email',
+ // An address the provider marks unverified is refused unless this is set.
+ allowUnverifiedEmail:
+ parseEnvBoolean(process.env.OIDC_ALLOW_UNVERIFIED_EMAIL, 'no') === 'yes',
+ // client_secret_basic | client_secret_post | none; empty = automatic.
+ tokenAuthMethod: String(process.env.OIDC_TOKEN_AUTH_METHOD || '').trim(),
+ // Login button appearance.
+ buttonText: String(process.env.OIDC_BUTTON_TEXT || '').trim(),
+ buttonIcon: String(process.env.OIDC_BUTTON_ICON || '').trim(),
+ buttonColor: String(process.env.OIDC_BUTTON_COLOR || '').trim(),
+ buttonTextColor: String(process.env.OIDC_BUTTON_TEXT_COLOR || '').trim(),
+});
+
// Initialize limit functions with defaults
const limitFunctions = {
activateTagging: parseEnvBoolean(process.env.ACTIVATE_TAGGING, 'yes'),
@@ -433,13 +459,14 @@ startupLog(logLevel, 'info', 'Configuration loaded:', {
});
module.exports = {
- PAPERLESS_AI_VERSION: 'v2026.09.03',
+ PAPERLESS_AI_VERSION: 'v2026.10.01',
CONFIGURED: false,
configSourceMode: CONFIG_SOURCE_MODE,
getApiKey,
getJwtSecret,
getTrustProxy,
getCookieSecureMode,
+ getOidcConfig,
isProtectedRuntimeEnvKey,
get apiKey() {
return getApiKey();
@@ -648,6 +675,33 @@ module.exports = {
deploymentName: process.env.AZURE_DEPLOYMENT_NAME || '',
apiVersion: process.env.AZURE_API_VERSION || '2023-05-15',
},
+ // The Claude API speaks its own Messages dialect, so it is a provider of
+ // its own rather than a preset of `custom`. Request rules per model live in
+ // services/anthropicSupport.js.
+ anthropic: {
+ apiKey: process.env.ANTHROPIC_API_KEY || '',
+ // Empty means api.anthropic.com; set only for a proxy in front of it.
+ apiUrl: process.env.ANTHROPIC_API_URL || '',
+ model: process.env.ANTHROPIC_MODEL || 'claude-haiku-5-5',
+ // low | medium | high | xhigh | max; empty leaves the model's default.
+ effort: String(process.env.ANTHROPIC_EFFORT || '')
+ .trim()
+ .toLowerCase(),
+ // Completion cap per request. Thinking counts toward it, which is why it
+ // is separate from RESPONSE_TOKENS.
+ maxTokens: parseTokenCount(
+ process.env.ANTHROPIC_MAX_TOKENS,
+ 16000,
+ 'ANTHROPIC_MAX_TOKENS'
+ ),
+ // Constrain the analysis answer to a JSON schema (output_config.format).
+ structuredOutput: parseEnvBoolean(
+ process.env.ANTHROPIC_STRUCTURED_OUTPUT,
+ 'yes'
+ ),
+ // Mark the system prompt cacheable; it repeats for every document of a scan.
+ promptCaching: parseEnvBoolean(process.env.ANTHROPIC_PROMPT_CACHING, 'yes'),
+ },
mistralOcr: {
enabled: parseEnvBoolean(process.env.MISTRAL_OCR_ENABLED, 'no'),
provider: String(
diff --git a/docker-compose.yml b/docker-compose.yml
index 861893ff..0d282a99 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -67,6 +67,15 @@ services:
# Update check (optional) – asks GitHub once a day whether a newer release
# exists. Set to 'no' for air-gapped installations; nothing else changes.
# - UPDATE_CHECK_ENABLED=no
+ # Single sign-on through OpenID Connect (optional), see docs/sso-oidc.md.
+ # The identity whose e-mail equals OIDC_ADMIN_EMAIL signs in as the admin.
+ # - OIDC_ENABLED=yes
+ # - OIDC_ISSUER_URL=https://auth.example.com/application/o/zettelrobbe/
+ # - OIDC_CLIENT_ID=your_client_id
+ # - OIDC_CLIENT_SECRET=your_client_secret
+ # - OIDC_REDIRECT_URI=https://zettelrobbe.example.com/auth/oidc/callback
+ # - OIDC_ADMIN_EMAIL=you@example.com
+ # - OIDC_BUTTON_TEXT=Sign in with SSO
ports:
- '3000:${PAPERLESS_AI_PORT:-3000}'
volumes:
diff --git a/docs/sso-oidc.md b/docs/sso-oidc.md
new file mode 100644
index 00000000..8b187e59
--- /dev/null
+++ b/docs/sso-oidc.md
@@ -0,0 +1,244 @@
+# Single Sign-On with OpenID Connect
+
+Zettelrobbe can sign you in through any standards-compliant OpenID Connect (OIDC) provider, for
+example Authentik, Authelia, Keycloak, Kanidm, Pocket ID, Zitadel, Microsoft Entra ID or Google.
+When it is configured, the login page shows an extra button next to the password form. One click
+takes you to your provider and back into Zettelrobbe.
+
+- [How it works](#how-it-works)
+- [Configuration](#configuration)
+- [Setting it up](#setting-it-up)
+- [Provider examples](#provider-examples)
+- [Customizing the login button](#customizing-the-login-button)
+- [Security notes](#security-notes)
+- [Troubleshooting](#troubleshooting)
+
+## How it works
+
+Zettelrobbe has exactly one account: the administrator created by the setup wizard. Single sign-on
+does not add users, it is a second way to sign in to that one account.
+
+1. You click the SSO button. Zettelrobbe sends you to your provider using the standard
+ authorization code flow with PKCE (S256), `state` and `nonce`.
+2. You sign in at the provider, which sends you back to `/auth/oidc/callback`.
+3. Zettelrobbe exchanges the code for tokens and validates the ID token (signature, issuer,
+ audience, expiry, nonce).
+4. It reads the e-mail address from the `email` claim (ID token, completed from the userinfo
+ endpoint) and compares it with `OIDC_ADMIN_EMAIL`, ignoring upper and lower case.
+5. If they match, you are signed in as the administrator, whatever your username is at the
+ provider. Any other identity is refused.
+
+Nothing is provider-specific. Endpoints, signing keys and the supported client authentication
+methods come from the provider's discovery document (`/.well-known/openid-configuration`).
+
+The password login keeps working next to SSO, so you can still get in when the provider is down.
+
+## Configuration
+
+SSO can be set on the settings page (**Settings › Sign-in and security › Single sign-on**) or
+through the environment variables below, usually in the `environment:` section of your
+`docker-compose.yml`. Both write the same `OIDC_*` values.
+
+- **On the settings page**, a value the container injects through the environment is shown locked
+ and cannot be changed there; remove it from the compose file to manage it on the page. The
+ client secret is never shown again after saving: leave the field empty to keep it. Saving with
+ SSO switched on is refused while the issuer URL, client ID, admin e-mail or redirect URI is
+ missing or invalid, so a broken setup cannot hide the button unnoticed. Switched off, the page
+ saves whatever you typed. Like every save, it restarts the application.
+- **Test connection** checks the values in the form before you save: it loads the provider's
+ discovery document, shows the token endpoint auth method the login would use and whether the
+ provider offers PKCE, and checks the client ID and secret at the token endpoint (with a made-up
+ authorization code, so nobody is signed in). With the secret field empty the test uses the
+ stored secret, but only for an issuer with the same scheme, host and port as the saved one.
+
+Single sign-on only switches on when
+`OIDC_ENABLED=yes` **and** the three required values are set. If one is missing or invalid, the
+button stays hidden and the log says what is missing at startup:
+
+```text
+[WARN] OIDC_ENABLED=yes but single sign-on stays off: OIDC_ADMIN_EMAIL is missing or not an e-mail address.
+```
+
+### Required
+
+| Variable | Example | Purpose |
+| ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| `OIDC_ENABLED` | `yes` | Turns single sign-on on. Default `no` |
+| `OIDC_ISSUER_URL` | `https://auth.example.com/application/o/zettelrobbe/` | The provider's issuer URL (see [Provider examples](#provider-examples)). The full discovery URL also works |
+| `OIDC_CLIENT_ID` | `zettelrobbe` | Client ID of the application registered at the provider |
+| `OIDC_ADMIN_EMAIL` | `you@example.com` | Your e-mail address at the provider. Only the identity with this address can sign in |
+
+### Usually needed
+
+| Variable | Default | Purpose |
+| -------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `OIDC_CLIENT_SECRET` | – | Client secret of a confidential client. Leave empty for a public client (PKCE is always used) |
+| `OIDC_REDIRECT_URI` | derived from the request | The callback URL registered at the provider: `https:///auth/oidc/callback`. Set it explicitly behind a reverse proxy |
+
+### Optional
+
+| Variable | Default | Purpose |
+| ----------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `OIDC_SCOPES` | `openid profile email` | Requested scopes. `openid` is always added. Keep `email`, it carries the address |
+| `OIDC_EMAIL_CLAIM` | `email` | Claim that holds the e-mail address, for providers that put it elsewhere |
+| `OIDC_ALLOW_UNVERIFIED_EMAIL` | `no` | Accept an address the provider marks `email_verified: false`. Only for providers that never confirm addresses; restrict the application at the provider then |
+| `OIDC_TOKEN_AUTH_METHOD` | automatic | How the client authenticates at the token endpoint: `client_secret_basic`, `client_secret_post` or `none`. Automatic picks `none` without a secret, otherwise `client_secret_basic` unless the provider only lists `client_secret_post` |
+| `OIDC_BUTTON_TEXT` | `Sign in with SSO` | Label of the login button (up to 60 characters) |
+| `OIDC_BUTTON_ICON` | key icon | Icon on the button: an `https://` image URL, a path on this server such as `/favicon.png`, or `none` |
+| `OIDC_BUTTON_COLOR` | theme default | Background colour of the button: `#rrggbb`, `#rgb`, a colour name, `rgb(...)` or `hsl(...)` |
+| `OIDC_BUTTON_TEXT_COLOR` | theme default | Text colour of the button, same formats |
+
+`OIDC_CLIENT_SECRET` is treated like the other secrets: it is masked in the settings page and in
+logs.
+
+## Setting it up
+
+These steps work for every provider. The [examples](#provider-examples) below fill in the
+provider-specific parts.
+
+1. **Register an application at your provider.**
+ - Flow: authorization code. Client type: confidential (with a secret) or public.
+ - Redirect URI: `https:///auth/oidc/callback`, exactly as the browser
+ reaches Zettelrobbe.
+ - Scopes: `openid profile email`.
+2. **Restrict access at the provider.** Allow only your own user (or a group with only you in it)
+ to use the application. See [Security notes](#security-notes).
+3. **Add the variables to your compose file.**
+
+ ```yaml
+ services:
+ zettelrobbe:
+ environment:
+ OIDC_ENABLED: 'yes'
+ OIDC_ISSUER_URL: https://auth.example.com/application/o/zettelrobbe/
+ OIDC_CLIENT_ID: zettelrobbe
+ OIDC_CLIENT_SECRET: ${ZETTELROBBE_OIDC_CLIENT_SECRET}
+ OIDC_REDIRECT_URI: https://zettelrobbe.example.com/auth/oidc/callback
+ OIDC_ADMIN_EMAIL: you@example.com
+ # Optional: make the button look like your provider
+ OIDC_BUTTON_TEXT: Sign in with Authentik
+ OIDC_BUTTON_COLOR: '#fd4b2d'
+ OIDC_BUTTON_TEXT_COLOR: white
+ # Behind a reverse proxy
+ TRUST_PROXY: '1'
+ ```
+
+ Keep the secret out of the compose file by putting it in the `.env` file next to it, as shown
+ with `${ZETTELROBBE_OIDC_CLIENT_SECRET}`.
+
+4. **Recreate the container** (`docker compose up -d`) and check the log for
+ `[INFO] Single sign-on enabled with issuer ...`.
+5. **Open the login page.** The SSO button appears below the password form.
+
+The setup wizard has to be finished first: SSO signs in to the administrator account, so that
+account must exist.
+
+## Provider examples
+
+### Authentik
+
+1. **Applications → Providers → Create → OAuth2/OpenID Provider.**
+ - Authorization flow: the default explicit or implicit consent flow.
+ - Client type: _Confidential_. Note the client ID and secret.
+ - Redirect URIs: `Strict`, `https://zettelrobbe.example.com/auth/oidc/callback`.
+ - Signing key: select a certificate (for example _authentik Self-signed Certificate_).
+2. **Applications → Applications → Create.** Name it, choose a slug (for example `zettelrobbe`)
+ and select the provider from step 1.
+3. **Restrict access:** on the application, open _Policy / Group / User Bindings_ and bind your
+ user or a group. Without a binding, every Authentik user may use the application.
+4. Issuer URL: `https:///application/o//`. The provider's overview page
+ shows it as _OpenID Configuration Issuer_.
+
+### Authelia
+
+Add a client to the Authelia configuration (Authelia 4.38 or later syntax):
+
+```yaml
+identity_providers:
+ oidc:
+ clients:
+ - client_id: 'zettelrobbe'
+ client_name: 'Zettelrobbe'
+ client_secret: '$pbkdf2-sha512$310000$...' # hashed secret
+ public: false
+ authorization_policy: 'two_factor'
+ redirect_uris:
+ - 'https://zettelrobbe.example.com/auth/oidc/callback'
+ scopes: ['openid', 'profile', 'email']
+ require_pkce: true
+ pkce_challenge_method: 'S256'
+ token_endpoint_auth_method: 'client_secret_basic'
+```
+
+Issuer URL: `https://`. Restrict the client with an access control rule or a
+dedicated authorization policy.
+
+### Keycloak
+
+1. In your realm: **Clients → Create client**, type _OpenID Connect_, client ID `zettelrobbe`.
+2. Turn _Client authentication_ on and keep _Standard flow_ enabled.
+3. _Valid redirect URIs_: `https://zettelrobbe.example.com/auth/oidc/callback`.
+4. Copy the secret from the _Credentials_ tab.
+
+Issuer URL: `https:///realms/`.
+
+### Other providers
+
+| Provider | `OIDC_ISSUER_URL` | Notes |
+| ------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Kanidm | `https:///oauth2/openid/` | The issuer is per client |
+| Pocket ID | `https://` | |
+| Zitadel | `https://` | |
+| Microsoft Entra ID | `https://login.microsoftonline.com//v2.0` | `email` is an optional claim in Entra ID. Add it to the token configuration, or set `OIDC_EMAIL_CLAIM=preferred_username` when the UPN is your e-mail address |
+| Google | `https://accounts.google.com` | |
+
+Any other provider works the same way as long as it publishes a discovery document and sends the
+e-mail address in the ID token or from its userinfo endpoint.
+
+## Customizing the login button
+
+With no customization the button is a neutral secondary button reading _Sign in with SSO_ with a
+key icon. Four variables change it:
+
+```yaml
+OIDC_BUTTON_TEXT: Sign in with Authentik
+OIDC_BUTTON_ICON: https://auth.example.com/media/zettelrobbe-icon.svg
+OIDC_BUTTON_COLOR: '#fd4b2d'
+OIDC_BUTTON_TEXT_COLOR: white
+```
+
+- `OIDC_BUTTON_ICON` accepts an `http(s)` URL or a path on the Zettelrobbe server. `none` removes
+ the icon. Anything else falls back to the key icon.
+- The colours accept `#rgb`, `#rrggbb` (with optional alpha), colour names, `rgb(...)` and
+ `hsl(...)`. Other values are ignored and the theme colour is used. Quote hex colours in YAML,
+ because `#` starts a comment.
+
+## Security notes
+
+- **Whoever passes the check is the administrator.** Zettelrobbe checks that the e-mail address
+ equals `OIDC_ADMIN_EMAIL` and refuses it when the provider marks it unverified
+ (`email_verified: false`, see `OIDC_ALLOW_UNVERIFIED_EMAIL`). Many providers let users change
+ their own e-mail address, so always restrict the application at the provider to your own account
+ or a group with only you in it.
+- **MFA belongs to the provider.** An SSO login does not ask for Zettelrobbe's own TOTP code.
+ Enforce MFA at the provider instead.
+- **Use HTTPS.** Plain-HTTP issuers work but log a warning. Use them only on a trusted network.
+- **Logging out ends the Zettelrobbe session only.** Your session at the provider stays open, so
+ the next click on the SSO button signs you in again without a password prompt. Log out at the
+ provider as well on a shared computer.
+- The sign-in transaction (state, nonce, PKCE verifier) lives in a signed, HTTP-only cookie that
+ expires after 10 minutes and can never be used as a session.
+- The SSO endpoints share the login rate limit (`LOGIN_RATE_LIMIT_MAX`).
+
+## Troubleshooting
+
+| Symptom | Cause and fix |
+| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| No SSO button on the login page | Check the startup log for `OIDC_ENABLED=yes but single sign-on stays off: ...` and set what it names |
+| _Single sign-on is unavailable: the identity provider could not be reached_ | Zettelrobbe could not load the discovery document. Check `OIDC_ISSUER_URL` and that the container can reach the provider (DNS, firewall, certificates). The log has the exact error |
+| The provider reports an invalid or mismatched redirect URI | The redirect URI must match the registered one character for character, including `https://`. Set `OIDC_REDIRECT_URI` to the full URL, and behind a reverse proxy also `TRUST_PROXY` |
+| _Single sign-on failed_ after returning from the provider | The token exchange failed. With `invalid_client` in the log, set `OIDC_TOKEN_AUTH_METHOD` to the method registered at the provider, and check the client secret |
+| _The identity provider did not send the "email" claim_ | Add `email` to `OIDC_SCOPES` and allow that scope for the client at the provider, or point `OIDC_EMAIL_CLAIM` at the claim that holds the address |
+| _This account may not sign in to Zettelrobbe_ | The e-mail address at the provider differs from `OIDC_ADMIN_EMAIL`. The log line `[FAILED LOGIN] OIDC sign-in refused for ...` shows the address the provider sent |
+| _Your single sign-on session expired_ | More than 10 minutes passed at the provider, or the browser dropped the cookie. Start again from the login page. If it keeps happening behind HTTPS, check `COOKIE_SECURE_MODE` and `TRUST_PROXY` |
+| _Zettelrobbe has no administrator account yet_ | Finish the setup wizard first |
diff --git a/package-lock.json b/package-lock.json
index 5d854c6d..2088145c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "@anthropic-ai/sdk": "^0.133.0",
"axios": "^1.20.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.11.1",
@@ -23,6 +24,7 @@
"jsonwebtoken": "^9.0.3",
"node-cron": "^4.6.0",
"openai": "^7.23.0",
+ "openid-client": "^6.8.8",
"qrcode": "^1.5.4",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
@@ -40,6 +42,27 @@
"stylelint-config-standard": "^40.0.0"
}
},
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.133.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.133.0.tgz",
+ "integrity": "sha512-jlqUN8p8ESTJj+GJnA4KjzbEfF1waEoGzRT8jwytlYGFFgv+/DUQOxlYGvYw10UzhD1kwLqYzxvA3FrTB2YWuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1",
+ "standardwebhooks": "^1.0.0"
+ },
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
@@ -149,6 +172,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
@@ -660,6 +692,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1130,9 +1168,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -1610,9 +1648,9 @@
}
},
"node_modules/dotenv": {
- "version": "18.0.3",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.3.tgz",
- "integrity": "sha512-/8CJTyL817qPnfbQrJgOEcrU3fDrJkRIoXBE566wHuvIXKOCHUZhPqGPbCPi9SogjdGnreF0TlvnTgK6J/8zpA==",
+ "version": "18.0.7",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.7.tgz",
+ "integrity": "sha512-QEvMEF8ca3GNZJJnAbOgxpj5ULhBvnMcURp+XKrtvqFNPgjBvYoswQ0Jl2cO7JzCjxZjJhr9ZuIEhlrRxMft/Q==",
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
@@ -2165,6 +2203,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
@@ -2830,9 +2874,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
- "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
+ "version": "10.7.4",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -2949,6 +2993,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/jose": {
+ "version": "6.2.12",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
+ "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2999,6 +3052,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-to-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "ts-algebra": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -3441,6 +3507,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/oauth4webapi": {
+ "version": "3.8.8",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
+ "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -3534,6 +3609,19 @@
"license": "MIT",
"peer": true
},
+ "node_modules/openid-client": {
+ "version": "6.8.8",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz",
+ "integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^6.2.12",
+ "oauth4webapi": "^3.8.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3800,9 +3888,9 @@
}
},
"node_modules/postcss-selector-parser": {
- "version": "7.1.5",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
- "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
+ "version": "7.1.6",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz",
+ "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3874,9 +3962,9 @@
}
},
"node_modules/proxy-addr": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -3884,6 +3972,10 @@
},
"engines": {
"node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/proxy-from-env": {
@@ -4646,9 +4738,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -4680,6 +4772,16 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/standardwebhooks": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
+ "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4732,9 +4834,9 @@
}
},
"node_modules/stylelint": {
- "version": "17.15.0",
- "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.15.0.tgz",
- "integrity": "sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA==",
+ "version": "17.16.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.16.0.tgz",
+ "integrity": "sha512-ejlWnbJ13x1GU32+zZXa6sYQJsKKkwSdv2B4rAdO3OWdEmvhSJKcXi5pOphQYSzyaj1kXpuGJr7pcMBNfDIuPw==",
"dev": true,
"funding": [
{
@@ -5184,6 +5286,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
+ "node_modules/ts-algebra": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+ "license": "MIT"
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
diff --git a/package.json b/package.json
index e92ab614..71e18f07 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"test:setup-remote-guard": "node scripts/run-tests.js --test setup-remote-guard",
"test:area:security": "node scripts/run-tests.js --area security",
"test:area:duplicates": "node scripts/run-tests.js --area duplicates",
+ "test:area:anthropic": "node scripts/run-tests.js --area anthropic",
"test:restriction-service": "node scripts/run-tests.js --test restriction-service",
"test:updated-service": "node scripts/run-tests.js --test updated-service",
"test:list": "node scripts/run-tests.js --list",
@@ -57,6 +58,7 @@
"author": "admonstrator",
"license": "MIT",
"dependencies": {
+ "@anthropic-ai/sdk": "^0.133.0",
"axios": "^1.20.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.11.1",
@@ -71,6 +73,7 @@
"jsonwebtoken": "^9.0.3",
"node-cron": "^4.6.0",
"openai": "^7.23.0",
+ "openid-client": "^6.8.8",
"qrcode": "^1.5.4",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
diff --git a/public/css/badges.css b/public/css/badges.css
index b6925366..bc875958 100644
--- a/public/css/badges.css
+++ b/public/css/badges.css
@@ -159,4 +159,10 @@
opacity: 1;
background: color-mix(in srgb, var(--zr-brand) 18%, transparent);
}
+ /* The default 16px icon is wider than its 15px button and stuck out past
+ the chip's rounded end. */
+ .zr-chip button .zr-icon {
+ width: 11px;
+ height: 11px;
+ }
}
diff --git a/public/css/pages/auth.css b/public/css/pages/auth.css
index ecd63c6b..97e8a6d1 100644
--- a/public/css/pages/auth.css
+++ b/public/css/pages/auth.css
@@ -108,4 +108,39 @@
.login-back-button {
align-self: center;
}
+
+ /* login: "or" rule between the password form and the single sign-on
+ button. */
+ .login-sso__divider {
+ display: flex;
+ align-items: center;
+ gap: var(--zr-3);
+ color: var(--zr-text-faint);
+ }
+ .login-sso__divider::before,
+ .login-sso__divider::after {
+ content: '';
+ flex: 1 1 auto;
+ height: 1px;
+ background: var(--zr-line);
+ }
+
+ /* login: the single sign-on button. OIDC_BUTTON_COLOR and
+ OIDC_BUTTON_TEXT_COLOR arrive as these two custom properties on the
+ element; without them it is a regular secondary button. */
+ .login-sso__button {
+ background: var(--login-sso-bg, var(--zr-surface));
+ border-color: var(--login-sso-bg, var(--zr-line-strong));
+ color: var(--login-sso-fg, var(--zr-text));
+ }
+ .login-sso__button:hover {
+ background: var(--login-sso-bg, var(--zr-surface-2));
+ border-color: var(--login-sso-bg, var(--zr-text-faint));
+ filter: brightness(0.95);
+ }
+ .login-sso__icon {
+ width: 18px;
+ height: 18px;
+ object-fit: contain;
+ }
}
diff --git a/public/css/pages/settings.css b/public/css/pages/settings.css
index 854f9ea8..6a3150ef 100644
--- a/public/css/pages/settings.css
+++ b/public/css/pages/settings.css
@@ -23,12 +23,9 @@
/* --- settings ------------------------------------------------------------ */
- /* The section nav navigates with plain #hash links, and the browser scrolls the
- target flush to the top of the viewport — where the sticky topbar sits on top
- of it. Every landing target gets the topbar plus the gap the section nav
- itself keeps below it, so a section lands where the nav says it is.
- js/modules/section-nav.js approximates the same offset for the scroll-spy. */
- .zr-settings :is([id$='-tab'], .zr-module[id^='sec-']) {
+ /* An old link to a section (#sec-provider) opens its topic and scrolls the
+ section into view; it lands below the sticky topbar, not under it. */
+ .zr-settings .zr-module[id^='sec-'] {
scroll-margin-top: calc(var(--zr-topbar-h) + var(--zr-4));
}
@@ -38,7 +35,7 @@
band — drawn the way .zr-table draws them, from card edge to card edge, which
the body's own padding has to be undone for.
- Scoped to a card's own body: #changelog-tab uses .zr-module__body as a bare
+ Scoped to a card's own body: the changelog uses .zr-module__body as a bare
layout wrapper outside any .zr-module, where the release timeline brings its
own rhythm and there is no card box for a full-bleed band to bleed into. */
.zr-settings .zr-module > .zr-module__body.zr-col--loose {
@@ -122,4 +119,228 @@
display: none;
}
}
+
+ /* --- one topic per view (js/modules/settings-nav.js) ------------------- */
+
+ /* The topic list is wider than the old section list: the entries are names
+ ("Schedule and tag filters"), not single words. */
+ .zr-settings {
+ grid-template-columns: 220px minmax(0, 1fr);
+ }
+
+ .zr-settings-nav {
+ gap: var(--zr-3);
+ }
+ .zr-settings-nav__group {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ }
+ .zr-settings-nav__label {
+ display: flex;
+ align-items: center;
+ gap: var(--zr-2);
+ padding: 0 var(--zr-2) var(--zr-1);
+ font-size: var(--zr-fs-xs);
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--zr-text-faint);
+ }
+ .zr-settings-nav a {
+ font-weight: 500;
+ }
+ /* A topic with edits that are not saved yet, wherever they are. */
+ .zr-settings-nav__dirty {
+ flex: none;
+ width: 7px;
+ height: 7px;
+ border-radius: var(--zr-r-full);
+ background: var(--zr-brand);
+ }
+ /* While searching, topics without a match step back but stay clickable. */
+ .zr-settings-nav__miss {
+ opacity: 0.4;
+ }
+
+ .zr-settings-picker .zr-input {
+ width: 100%;
+ }
+
+ /* The search sits above both columns; its results float over the page. */
+ .zr-settings-search__box {
+ position: relative;
+ flex: 1 1 320px;
+ max-width: 520px;
+ }
+ .zr-settings-search__box > .zr-icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--zr-text-faint);
+ pointer-events: none;
+ }
+ .zr-settings-search__box > .zr-input {
+ width: 100%;
+ padding-left: 32px;
+ }
+ .zr-settings-search__results {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + var(--zr-1));
+ left: 0;
+ right: 0;
+ max-height: min(420px, 60dvh);
+ overflow-y: auto;
+ padding: var(--zr-1);
+ background: var(--zr-surface);
+ border: 1px solid var(--zr-line);
+ border-radius: var(--zr-r-lg);
+ box-shadow: var(--zr-shadow-lg);
+ }
+ .zr-settings-search__item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0 var(--zr-2);
+ width: 100%;
+ padding: var(--zr-2);
+ border: 0;
+ border-radius: var(--zr-r-md);
+ background: transparent;
+ color: var(--zr-text);
+ text-align: left;
+ cursor: pointer;
+ }
+ .zr-settings-search__item:hover,
+ .zr-settings-search__item[aria-selected='true'] {
+ background: var(--zr-brand-soft);
+ }
+ .zr-settings-search__name {
+ font-weight: 600;
+ }
+ .zr-settings-search__path {
+ grid-column: 1;
+ font-size: var(--zr-fs-xs);
+ color: var(--zr-text-muted);
+ }
+ .zr-settings-search__key {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ align-self: center;
+ font-family: var(--zr-font-mono);
+ font-size: var(--zr-fs-xs);
+ color: var(--zr-text-muted);
+ }
+ .zr-settings-search__empty {
+ padding: var(--zr-2);
+ }
+
+ /* The topic's own head: group label, then the topic as the page heading. */
+ .zr-settings-entry__head {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .zr-settings-entry__titlerow {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--zr-2);
+ }
+ .zr-settings-entry__title {
+ font-family: var(--zr-font-brand);
+ font-size: var(--zr-fs-xl);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ }
+ /* "On, but incomplete: ..." can be a sentence; it wraps instead of
+ running out of the column. */
+ .zr-settings-entry__status {
+ white-space: normal;
+ max-width: 100%;
+ }
+
+ /* The variable a field writes, named at the start of its hint. */
+ .zr-envkey {
+ font-family: var(--zr-font-mono);
+ font-size: 0.95em;
+ color: var(--zr-text-muted);
+ }
+
+ /* The row a search result or an error opened, for a moment. */
+ .zr-settings-flash {
+ animation: zr-settings-flash 1.6s var(--zr-ease);
+ }
+ @keyframes zr-settings-flash {
+ 0%,
+ 40% {
+ background: var(--zr-brand-soft);
+ }
+ 100% {
+ background: transparent;
+ }
+ }
+
+ /* --- single sign-on ---------------------------------------------------- */
+ .zr-sso-preview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--zr-2);
+ }
+ /* A slice of the login card, on the login page's own canvas. */
+ .zr-sso-preview__card {
+ max-width: 360px;
+ padding: var(--zr-4);
+ border: 1px solid var(--zr-line);
+ border-radius: var(--zr-r-lg);
+ background: var(--zr-canvas);
+ }
+ .zr-sso-preview__card .login-sso__button {
+ pointer-events: none;
+ }
+ .zr-sso-swatch {
+ flex: none;
+ align-self: stretch;
+ width: 30px;
+ border: 1px solid var(--zr-line-strong);
+ border-radius: var(--zr-r-md);
+ background: repeating-conic-gradient(
+ var(--zr-surface-2) 0% 25%,
+ var(--zr-surface) 0% 50%
+ )
+ 50% / 10px 10px;
+ }
+ .zr-sso-swatch[data-state='invalid'] {
+ border-color: var(--zr-danger);
+ }
+ .zr-input[aria-invalid='true'] {
+ border-color: var(--zr-danger);
+ }
+
+ .zr-sso-advanced > summary {
+ cursor: pointer;
+ list-style: none;
+ }
+ .zr-sso-advanced > summary::-webkit-details-marker {
+ display: none;
+ }
+ .zr-sso-advanced:not([open]) > summary {
+ border-bottom: 0;
+ }
+ .zr-sso-advanced[open] > summary > .zr-icon {
+ transform: rotate(180deg);
+ }
+
+ @media (max-width: 860px) {
+ .zr-settings {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .zr-settings-search__box {
+ max-width: none;
+ }
+ .zr-settings-entry__title {
+ font-size: var(--zr-fs-lg);
+ }
+ }
}
diff --git a/public/css/pages/shared.css b/public/css/pages/shared.css
index 94fa05a4..0aea7d3a 100644
--- a/public/css/pages/shared.css
+++ b/public/css/pages/shared.css
@@ -1,5 +1,5 @@
/* ==========================================================================
- Cross-page legacy pieces: the pre-framework modal system, .stat-box,
+ Cross-page legacy pieces: the pre-framework modal system,
the manual document search, the queue toolbar and the odds and ends
the page scripts of history, settings and manual generate
at runtime. Not one page — several, which is why they are not in any
@@ -163,23 +163,37 @@
transition: filter 0.3s ease;
}
- /* Stats Box */
- .stat-box {
- background: var(--zr-surface-2);
- padding: 1rem;
- border-radius: 8px;
- border: 1px solid var(--zr-line);
- transition: all 0.2s ease;
- color: var(--zr-text);
- }
-
/* The manual preview claims its height up front, so picking a document does
- not make the rest of the page jump. Frame and padding come from .stat-box. */
- /* Tall enough to balance the analysis column once content loads; while empty
+ not make the rest of the page jump. Frame and padding come from .zr-panel.
+ Tall enough to balance the analysis column once content loads; while empty
the #previewEmpty hint fills it instead of a bare grey area. */
.manual-preview {
min-height: 360px;
}
+ /* Document text keeps its own line breaks and scrolls inside the panel: a
+ long document used to run together into one paragraph and stretch the
+ page to its full length. */
+ .manual-preview #contentPreview {
+ max-height: 600px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-size: var(--zr-fs-sm);
+ }
+
+ /* An empty tag list says so instead of leaving its label over nothing. */
+ .manual-tags:empty::before {
+ content: attr(data-empty);
+ font-size: var(--zr-fs-sm);
+ color: var(--zr-text-faint);
+ }
+ /* A suggestion is not on the document yet: outlined rather than filled. The
+ padding gives back the border's pixel so both chips stand equally tall. */
+ .manual-chip--suggested {
+ padding: 1px 3px 1px 7px;
+ background: transparent;
+ border: 1px dashed color-mix(in srgb, var(--zr-brand) 55%, transparent);
+ }
/* The empty hint shows only while the preview has no content. */
.manual-preview #contentPreview:not(:empty) + #previewEmpty {
display: none;
@@ -190,12 +204,9 @@
.manual-preview {
min-height: 160px;
}
- }
-
- .stat-box:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
- background: var(--zr-surface-2);
+ .manual-preview #contentPreview {
+ max-height: 320px;
+ }
}
/* Modal Styles */
diff --git a/public/js/bug-report-link.js b/public/js/bug-report-link.js
index d1d905ac..476d7890 100644
--- a/public/js/bug-report-link.js
+++ b/public/js/bug-report-link.js
@@ -78,6 +78,8 @@
`AZURE_ENDPOINT=${supportInfo.azureEndpoint || 'unknown'}`,
`AZURE_DEPLOYMENT_NAME=${supportInfo.azureDeploymentName || 'unknown'}`,
`AZURE_API_VERSION=${supportInfo.azureApiVersion || 'unknown'}`,
+ `ANTHROPIC_MODEL=${supportInfo.anthropicModel || 'unknown'}`,
+ `ANTHROPIC_API_URL=${supportInfo.anthropicApiUrl || 'unknown'}`,
`MISTRAL_OCR_MODEL=${supportInfo.mistralOcrModel || 'unknown'}`,
`TRUST_PROXY=${supportInfo.trustProxy || 'unknown'}`,
`USE_EXISTING_DATA=${supportInfo.useExistingData || 'no'}`,
@@ -88,6 +90,7 @@
`OPENAI_API_KEY=${keyState(supportInfo.openAiKeySet)}`,
`CUSTOM_API_KEY=${keyState(supportInfo.customKeySet)}`,
`AZURE_API_KEY=${keyState(supportInfo.azureKeySet)}`,
+ `ANTHROPIC_API_KEY=${keyState(supportInfo.anthropicKeySet)}`,
`MISTRAL_API_KEY=${keyState(supportInfo.mistralKeySet)}`,
`API_KEY=${keyState(supportInfo.apiKeySet)}`,
].join('\n')
diff --git a/public/js/duplicates.js b/public/js/duplicates.js
index b216973b..6bd0d1be 100644
--- a/public/js/duplicates.js
+++ b/public/js/duplicates.js
@@ -2125,7 +2125,7 @@ async function reattachReview() {
*/
function htmlReviewFailure(message) {
const text = String(message == null ? '' : message);
- return `