feat(settings): KI-provider — OpenAI key field, model preset dropdown, gated OAuth

Bucket C of the v1.4.3 polish pass.

Three independent bugs in the KI-provider settings, fixed together
because they all live on the same panel:

1. **Selecting "OpenAI" left no way to provide a key.** The schema
   stored Anthropic and Local API keys, but OPENAI was a valid
   `aiProvider` value with no matching credential column — the
   resolver silently fell through to the admin-level OpenAI key. So
   a self-hosted user who picked OpenAI and typed nothing got the
   admin's key (might not exist), and a user who *wanted* their own
   OpenAI key had no UI surface to enter it.

   Migration `0026_user_ai_openai_key` adds `users.ai_openai_key_
   encrypted`. The resolver's `OPENAI` branch now picks up the
   user-level key first and only falls back to admin if the user
   slot is empty. The settings UI grew an OpenAI-key field gated on
   `provider === "OPENAI"` mirroring the Anthropic flow. The
   `/api/ai/test` override schema accepts `openaiKey` so the
   "Verbindung testen" button can validate an unsaved key without
   persisting plaintext.

2. **Model field was a freetext Input, easy to typo.** Replaced
   with a `<select>` populated from `MODEL_PRESETS` per provider:
   gpt-4o / gpt-4o-mini / gpt-4-turbo / gpt-5 / o3-mini for OPENAI;
   claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / claude-
   3-5-sonnet-latest for ANTHROPIC; llama3.1:8b / llama3.1:70b /
   mistral / qwen2.5 for LOCAL. An "Eigenes…" sentinel still
   surfaces the freetext Input so power users can target preview
   models or fine-tunes. The dropdown disables when no provider is
   chosen — saves the user from filling a model that won't be used.

3. **"Mit ChatGPT verbinden" silently bounced to chatgpt.com login
   with no OAuth grant.** The authorize URL was built without a
   `client_id` parameter, so chatgpt.com couldn't recognise the
   request as an OAuth handshake — it just rendered the regular
   login form. The user came back, nothing connected, the button
   "did nothing".

   Added `CODEX_OAUTH_CLIENT_ID` env var. The URL builder now
   throws `CodexOAuthNotConfiguredError` if it's unset; the
   authorize route maps that to a 503 with an actionable message
   instead of dispatching the dead URL. The settings card reads
   `codexOauthConfigured` from `/api/insights/settings` and hides
   the Connect button when the flag is false, replacing it with a
   tiny italicised "ChatGPT-OAuth ist auf dieser Instanz nicht
   konfiguriert — nutze stattdessen einen eigenen API-Key unten."
   `CHATGPT_OAUTH` was also removed as a manually-selectable
   provider option in the dropdown — the top connect button is the
   path; having both confused the affordance.

Refresh + token-exchange both forward the client_id now too. Test
fixture seeds `process.env.CODEX_OAUTH_CLIENT_ID="test_client_id"`
in `beforeEach` so the existing URL-shape assertion still works,
plus a new test asserts the unset case throws.

Tests + typecheck green (670/670 — one new assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-08 21:36:36 +02:00
1 parent 622099c0fd
commit c05f48f520
10 files changed
+317 -49

No files matched your search

+13
View File
@@ -22,6 +22,7 @@ export const GET = apiHandler(async () => {
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
},
});
@@ -34,6 +35,10 @@ export const GET = apiHandler(async () => {
? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}`
: null,
hasLocalKey: Boolean(u?.aiLocalKeyEncrypted),
hasOpenaiKey: Boolean(u?.aiOpenaiKeyEncrypted),
openaiKeyPreview: u?.aiOpenaiKeyEncrypted
? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}`
: null,
});
});
@@ -100,6 +105,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
}
}
if (body.openaiKey !== undefined) {
if (body.openaiKey === null || body.openaiKey === "") {
updates.aiOpenaiKeyEncrypted = null;
} else if (typeof body.openaiKey === "string") {
updates.aiOpenaiKeyEncrypted = encrypt(body.openaiKey.trim());
}
}
if (Object.keys(updates).length === 0) {
throw new HttpError(422, "No valid fields");
}