feat: credentials, per-user LLM settings, API keys management, oauth2-proxy integration - #24
Conversation
…-proxy integration - withCredentials: true on all axios instances (apiClient, ragApiClient, mcpApiClient) + credentials: 'include' on SSE fetchEventSource for cross-subdomain cookie auth via oauth2-proxy. - 401 redirect: on 401 from any backend, redirect to /oauth2/start?rd=<url> with infinite-loop guard (no redirect if already on auth page). - Logout: Sign out button -> /oauth2/sign_out. - Per-user LLM settings: Settings page LLM Provider card persists to backend PUT /api/v1/settings/llm (provider, base_url, api_key) instead of localStorage. GET shows masked key. DELETE removes. Replaces old localStorage apiKey/llmProvider. - Per-user API keys management: new API Keys card in Settings page — list (GET /api/v1/api-keys), create (POST -> plaintext shown ONCE with copy button + warning), revoke (DELETE with confirm dialog). - Hexagonal: new domain entities (LlmSettings, ApiKey), ports (ISettingsPort, IApiKeyPort), adapters (settingsApi, apiKeyApi), hooks (useLlmSettings, useApiKeys), components (LlmSettingsCard, ApiKeysCard). Tests: 780 unit tests pass (+34 new). QA: front builds + serves (2 health tests pass). e2e settings/api-keys UI deferred to oauth2-proxy (ticket 4).
Kaiohz
left a comment
There was a problem hiding this comment.
Code review — PR #24 (credentials, LLM settings, API keys, oauth2-proxy)
Scope: 1968+/154- across 33 files. Hexagonal split respected (domain ports → infrastructure adapters → application hooks → views). 780 tests pass (+34 new). Cookie auth + 401 redirect + per-user settings + plain key shown once.
✅ Positives
- Hexagonal boundaries clean.
ISettingsPort/IApiKeyPortindomain/,settingsApi/apiKeyApiininfrastructure/,useLlmSettings/useApiKeysinapplication/hooks/. View components only consume the hooks. This is the right shape and matches the rest of the codebase. - Plaintext key flow is correct.
CreatedApiKey.plaintextcarried once,CreatedKeyDialogshows it + copy + "you won't see this again" warning, never persisted. Hexagonal contract (apiKeyApi.createreturnsCreatedApiKey) is honest about the one-shot exposure. - Migration is safe. New
migratePersistedstrips legacyllmProvider/apiKeyfrom the persisted Zustand blob before re-hydrating, so existing users don't carry stale fields. Good defensive comment. - 401 redirect guard is genuine.
isOnAuthPage()checks thepathnameagainst both/oauth2/startand/oauth2/sign_out, so a 401 fired during the auth round-trip won't loop. Tests cover the loop-guard case explicitly. withCredentialson every axios instance + SSEcredentials: "include"— cookie auth is consistent across REST and streaming. No risk of one channel silently dropping the cookie.- AlertDialog via Radix is the right primitive (focus trap, Escape, ARIA). The "Confirm revoke" confirmation is a real UX win over a plain
window.confirm. - Tests are tight, not theatrical.
axiosInstances.test.tsactually intercepts the rejected handler and asserts onhrefSetter; the loop-guard test overrideswindow.locationto verify no redirect fires.useApiKeys.test.tsx/useLlmSettings.test.tsxare minimal hook contracts. - Race-avoidance in
LlmSettingsCard. TheuseEffectdeps list (settingsProvider,settingsBaseUrl,settingsUpdatedAt) — explicitly chosen over thesettingsobject identity, with a comment explaining why. This is the kind of thing that gets done wrong by default; doing it right is worth a shout-out.
🔎 Findings
1. (LlmSettingsCard.tsx ~lines 65–85) useEffect deps list is partially redundant + can race on first render
The effect resets the form whenever settingsProvider, settingsBaseUrl, or settingsUpdatedAt change. On the very first render after isLoading flips false, settings?.updated_at is whatever the backend returned, so the effect fires once with non-undefined values — fine. But the form defaultValues is also { provider: "openai", base_url: …openai, api_key: "" }, so you have two sources of truth. If the backend ever returns provider: "openrouter" but the user submits before the effect runs, the setValue("provider", value) call in onProviderChange will fight the reset() in the effect. Not a bug today (the effect runs before user interaction), but the dual ownership is fragile. Consider setting defaultValues based on settings ?? DEFAULTS instead of always "openai", and remove the effect reset entirely — react-hook-form won't re-render from external state unless you tell it to.
2. (LlmSettingsCard.tsx ~lines 95–106) onProviderChange auto-fills base_url only when the current URL is a default — but the comparison is wrong-ish
const isDefaultUrl = Object.values(PROVIDER_DEFAULTS).includes(baseUrl);
if (next && (!baseUrl || isDefaultUrl)) { setValue("base_url", next); }This overwrites the URL when the user has selected a known default, even if they intentionally put that URL in for a different provider (e.g. litellm pointed at https://api.openai.com/v1). Better: only autofill when the user is switching from a provider whose default they're currently using, or drop the auto-fill entirely and let users type. Smaller blast radius.
3. (ApiKeysCard.tsx ~line 175) useApiKeys().create returns the plaintext, but the useApiKeys hook never re-reads it on remount
The plaintext is captured in ApiKeysCard via setCreated(result). If the user dismisses the CreatedKeyDialog and the parent component remounts (e.g. navigating away and back to /settings), the plaintext is gone from React state — correct, intentional. But if the user submits twice quickly (double-click on "Create key"), createMutation will fire twice and you'll get two distinct plaintexts. The disabled guard on the submit button is missing. Consider disabled={isCreating} on the submit button and on the per-row revoke button.
4. (SettingsPage.tsx ~line 80) "Sign out" button is in the page header and visible to every visitor
That's fine if the cookie auth gate already prevented reaching this page without a session. With oauth2-proxy in front, an unauthenticated visitor should never see /settings — they'd be redirected to /oauth2/start first. Confirm: is the page behind a route guard, or only guarded by the 401 interceptor? If the latter, the visible "Sign out" button is redundant (the interceptor would already redirect on a 401) and exists primarily for in-session use, which is fine but worth a comment.
5. (useApiKeys.ts ~line 35) useApiKeys exposes revoke but the ApiKeysCard revocation flow bypasses the isRevoking guard
Looking at ApiKeysCard.tsx, the Revoke button is disabled only when isRevoking is true (via the hasRevokeButton check), but the AlertDialogAction is not disabled during the in-flight revoke. A double-click on "Confirm revoke" can fire two DELETE requests. Same as #3.
6. (oauth2Redirect.ts) isOnAuthPage() does a substring/prefix match on pathname only
const pathname = href.includes("://") ? new URL(href).pathname : window.location.pathname;The ternary is needed because window.location.href is sometimes a relative path in jsdom, but on a real browser it's always absolute. The branch is dead code in production. Not a bug — just dead-path complexity. Suggest a single new URL(window.location.href).pathname with a try/catch.
7. (axiosInstances.test.ts) vi.resetModules() in afterEach could mask module-load order bugs
Each test re-imports the axios instance module, which means cache state (interceptors, defaults) is recreated per test. Good for isolation, but if one test installs a handler and another doesn't re-import, you can get false positives. The pattern is fine; just note it's intentional.
8. (nit) LLM_PROVIDERS list dropped anthropic, google, mistral, local, ollama — replaced with openrouter, litellm, custom
The previous list and the new list are nearly disjoint. This is a breaking change disguised as a refactor. The README mentions this implicitly ("'anthropic' / 'openai' / 'google' / 'mistral' / 'local' / 'ollama'"), but a real user on anthropic will silently lose their config on upgrade. Either:
- preserve the old list with a server-side default mapping, or
- explicitly call this out as a breaking change in the PR description (the "Breaking changes" section only mentions
localStorage → backend, not the provider list).
9. (nit) useSettingsStore.ts migratePersisted is a no-op when the blob was already migrated
If a user has had the app for a while and has a clean state, migratePersisted strips nothing. That's fine. But the function has the same signature as migrateFontFamily and could be merged into a single migrate(persisted) function. Not blocking.
10. (nit) ApiKeysCard.tsx formatDate uses toISOString() which is UTC; the UI then shows UTC dates to a user in, say, Asia/Ho_Chi_Minh
For a settings page, locale-aware formatting (toLocaleString()) is usually what users expect. UTC strings in a UTC+7 timezone = "weird hours on the wrong day" UX. Minor.
Score
7.5 / 10
Why not higher: Finding #8 (breaking provider list) needs a clarifying line in the PR description. Finding #3 + #5 (double-click guards missing) are real bugs that the test suite doesn't cover. Finding #1 (dual ownership of form state) is a mild design smell.
Why not lower: The hexagonal split is textbook, the plaintext flow is correctly one-shot, the migration is safe, the auth guard is genuine, and the tests are real (not just coverage theater). The CI is green, the docs are updated, and the dependency on the backend PR is explicit. The breaking change is documented at the category level — just missing the provider-list detail.
Suggested improvements
- Add
disabled={isCreating}to the API key create button anddisabled={isRevoking}to the confirmation action. - Add a test for the "two rapid clicks don't fire two POSTs" case on both LLM upsert and API key create.
- Either (a) extend
LLM_PROVIDERSto keep the legacy four and only add the new ones, or (b) add a line in the PR description: "Provider values are now backend-defined; the dropdown lists 4 supported providers. If you were onanthropicorgoogle, choose the closest equivalent (openai/custom) and re-enter your API key." - Locale-format the
created_at/last_used_attimestamps inApiKeysCard. - Drop the
href.includes("://")ternary inisOnAuthPage()— production is always absolute. - Optional: write a single integration test that does
POST /api/v1/api-keys→ assertplaintextpresent → assertGET /api/v1/api-keysdoes NOT returnplaintext— this is the contract worth pinning down so a server-side refactor doesn't accidentally regress the one-shot exposure.
Otherwise: solid PR. The dependency on composable-agents#39 and the flux ticket 4 are explicit, which is the right way to gate a multi-repo auth change.
Use Array.isArray() guard instead of (servers ?? []).map() which crashes when the API returns a non-array (e.g. error object, null response).
Kaiohz
left a comment
There was a problem hiding this comment.
Code review — PR #24 (f6fb0607, head)
Scope: +1969 / -155 across 33 files. Hexagonal split respected (domain ports → infrastructure adapters → application hooks → views). 780 unit tests pass (+34 new). Cookie-based auth via oauth2-proxy + 401 redirect + per-user LLM settings + per-user API keys + plaintext one-shot exposure.
Reviewed at: f6fb06070170897e93ec58b289a0e171315a56dc (follow-up fix: McpServerGrid crash when servers is not an array included).
✅ Positives
- Hexagonal boundaries are clean.
ISettingsPort/IApiKeyPortdeclared indomain/ports/, implemented bysettingsApi/apiKeyApiininfrastructure/, consumed byuseLlmSettings/useApiKeysinapplication/hooks/. Views (LlmSettingsCard,ApiKeysCard) only touch the hooks. Matches the rest of the codebase. - Plaintext key flow is correct.
CreatedApiKey.plaintextis carried once from the adapter, surfaced byCreatedKeyDialog(copy + "you won't see this again" warning), never persisted in Zustand or localStorage. The contract is honest about the one-shot exposure. - Migration is safe. New
migratePersistedstrips the legacyllmProvider/apiKeyfrom the persisted Zustand blob before re-hydrating, so existing users don't carry stale fields. Defensive comment is appreciated. - 401 redirect guard is genuine.
isOnAuthPage()matches thepathnameagainst both/oauth2/startand/oauth2/sign_out, so a 401 fired during the auth round-trip won't loop. Tested explicitly inaxiosInstances.test.ts. withCredentialsis consistent across REST and SSE. Every axios instance has it, plus thefetchEventSourcecall inchatApisetscredentials: "include". No channel silently drops the cookie.AlertDialogvia Radix is the right primitive (focus trap, Escape, ARIA). The confirm-revoke UX is a real win overwindow.confirm.- Tests are real, not theatrical.
axiosInstances.test.tsintercepts the rejected handler and asserts onhrefSetter; the loop-guard test overrideswindow.locationto verify no redirect fires. Hook tests are minimal contracts. - Form re-sync in
LlmSettingsCardis thoughtful. Deps list (settingsProvider,settingsBaseUrl,settingsUpdatedAt) — explicitly chosen over thesettingsobject identity, with a comment explaining why. The kind of thing that gets done wrong by default. - Dependency on
composable-agents#39andfluxticket 4 is explicit in the PR body. Right way to gate a multi-repo auth change.
🔎 Findings
1. (Bug, LlmSettingsCard.tsx ~line 65–85) useEffect form reset duplicates defaultValues ownership
The form is initialised with defaultValues: { provider: "openai", base_url: …openai, api_key: "" } and an effect that calls reset(...) whenever the backend's provider / base_url / updated_at change. If the backend returns provider: "openrouter" and the user submits before the effect runs (rare but possible during a refetch), the setValue("provider", value) in onProviderChange and the reset() in the effect can fight. Two ways to fix:
- Remove the effect entirely and set
defaultValuesfromsettings ?? DEFAULTS. RHF won't reset from external state unless told to. - Keep the effect but drop the
setValueinonProviderChange(use the effect as the single source of truth).
2. (Bug, LlmSettingsCard.tsx ~line 95–106) onProviderChange overwrites a user-typed base_url
const isDefaultUrl = Object.values(PROVIDER_DEFAULTS).includes(baseUrl);
if (next && (!baseUrl || isDefaultUrl)) { setValue("base_url", next); }A user pointing litellm at https://api.openai.com/v1 (intentional proxy) loses the URL the moment they switch the provider dropdown. Either drop the auto-fill entirely, or only autofill when switching from the same provider whose default they're currently using.
3. (Bug, ApiKeysCard.tsx) Missing disabled guards → double-click can fire two POSTs / DELETEs
- Create button: should be
disabled={isCreating}(not just visually). AlertDialogAction"Confirm revoke": should bedisabled={isRevoking}.
Same class of bug as in composable-agents reviews: the test suite doesn't cover the rapid-click path, so the regression won't show up in CI.
4. (Bug / contract, chatApi.ts ~line 115) SSE 401 detection is fragile
onerror(err) {
if (err && typeof err === "object" && "status" in err && err.status === 401) {
redirectToSignIn();
}
onError(err instanceof Error ? err : new Error(String(err)));
throw err;
},fetchEventSource doesn't normalize err — status may live on err.status, err.response?.status, or not be set at all (network errors). The 401 path will be missed on transport errors. Either introspect err more carefully or use a fetch wrapper that exposes the response status. Worth a test that simulates a 401 from the SSE endpoint.
5. (Bug / contract, oauth2Redirect.ts) Dead branch in isOnAuthPage()
const pathname = href.includes("://")
? new URL(href).pathname
: (window.location.pathname ?? "");In production, window.location.href is always absolute — the ternary only exists for jsdom. Replace with new URL(window.location.href).pathname inside a try/catch. The current code works but the dead path is misleading on read.
6. (Breaking change, undocumented) LLM_PROVIDERS list was silently rewritten
Old list (in the diff of SettingsPage.tsx): anthropic, openai, google, mistral, local. New list: openai, openrouter, litellm, custom. The PR description's "Breaking changes" section only mentions localStorage → backend, not the provider list. A user on anthropic or google will silently lose their config on upgrade.
- Either preserve the old list (backend can map), or
- Add a line in the PR description: "Provider values are now backend-defined; the dropdown lists 4 supported providers. Re-enter your key on first save."
7. (UX, ApiKeysCard.tsx formatDate) UTC dates shown to a non-UTC user
return d.toISOString().slice(0, 19).replace("T", " ");Hardcoded UTC. A user in Asia/Ho_Chi_Minh will see "weird hours on the wrong day". Use toLocaleString() (or Intl.DateTimeFormat) for human display.
8. (Defense-in-depth, useSettingsStore.ts migratePersisted)
Works, but the prefix-less underscore for destructured names (_llm, _key) is the only "we explicitly don't use these" signal. A short JSDoc would make it clearer to future readers why those two keys are filtered.
9. (Test coverage gap) No integration test for the "plaintext shown once, never on subsequent GET" contract
The most important invariant of this PR — that the backend never returns plaintext after creation — has no test pinning it down. A server-side refactor that accidentally starts returning plaintext on GET /api/v1/api-keys would not be caught by any current test. Add a contract test (mock-level is fine) that asserts list() result has no plaintext field.
10. (Test coverage gap) axiosInstances.test.ts vi.resetModules() in afterEach
Intentional for isolation, but worth a one-line comment so a future reader doesn't think it's a bug. Each test re-imports the axios module → state is fresh per test. Note it as such.
Score
7.5 / 10
Why not higher: Finding #6 (breaking provider list) needs a clarifying line in the PR description. Findings #3 (double-click guards) and #4 (SSE 401 detection) are real bugs the test suite doesn't cover. Finding #1 (dual form ownership) is a mild design smell that will bite the next refactor.
Why not lower: The hexagonal split is textbook. The plaintext flow is correctly one-shot. The migration is safe. The auth guard is genuine. The tests are real (not coverage theater). CI is green, docs are updated, the cross-repo dependencies are explicit, and the head fix (McpServerGrid Array.isArray guard) is the right shape.
Suggested follow-ups (in priority order)
- Add
disabled={isCreating}/disabled={isRevoking}to the create + confirm-revoke buttons, plus a test for "two rapid clicks don't fire two POSTs / DELETEs". Same bug class as the other double-click regressions across SoluDevTech repos — worth a one-line guard everywhere. - Document the provider-list breaking change in the PR description (one sentence is enough).
- Add a contract test that asserts
apiKeyApi.list()result has noplaintextfield. Pins down the one-shot exposure invariant. - Fix the SSE 401 detection so 401s on the streaming endpoint actually trigger the redirect. Add a test.
- Locale-format the API key timestamps (
toLocaleString). - Drop the auto-fill
base_urllogic inonProviderChangeor narrow it to the same-provider case. Lower priority — the user can re-type the URL.
Otherwise: solid PR. The dependency on composable-agents#39 and flux ticket 4 is the right way to gate a multi-repo auth change, and the migration of localStorage → backend settings is handled defensively.
Replace the hardcoded 'Yohan / YH' values with the real authenticated user profile fetched from the new GET /api/v1/users/me backend endpoint. Falls back gracefully (username -> email local part -> userId) when JWT claims are partial or absent (API-key auth). - UserProfile entity + getDisplayName/getInitials helpers - ICurrentUserPort + userApi adapter (/api/v1/users/me) - useCurrentUser hook (react-query, staleTime: Infinity) - SidebarFooter: dynamic name + initials (loading placeholder) - ChatMessage: human avatar initial derived from the profile - Tests: helpers, hook (success/error/single-call), Sidebar updated
Kaiohz
left a comment
There was a problem hiding this comment.
Review — feat: credentials, per-user LLM settings, API keys management, oauth2-proxy integration
Score: 8.5 / 10
Beau boulot sur cette PR. L'architecture hexagonale est respectée de bout en bout, le port → adapter → hook → component est appliqué de manière cohérente, et la sécurité du flux credentials est prise au sérieux (plaintext-once, masquage, cookie-only, redirect 401 loop-guarded). Les tests unitaires sur les hooks et adapters sont solides (119 fichiers / 780 tests verts côté CI). Quelques points d'amélioration à regarder avant merge.
✅ Points forts
- Architecture hexagonale propre : entités/ports/adapters séparés,
IApiKeyPort/ISettingsPort/ICurrentUserPortclairement découpés, dépendances bien orientées (domain ← application ← infrastructure). - Credentials bien gérés :
withCredentials: truesur les 3 axios +credentials: "include"sur le SSE, masquage serveur, plaintext-once côté API key avec copy + warning explicite, aucun secret en localStorage après migration. - Migration de store :
migratePersisted()dansuseSettingsStorestrip proprement lesllmProvider/apiKeylegacy → évite que d'anciens secrets restent dans le localStorage des users qui avaient déjà sauvegardé. - Loop guard sur 401 :
isOnAuthPage()empêche la boucle infinie sur/oauth2/start. - Détails UX soignés :
data-od-idpartout,AlertDialogRadix,CreatedKeyDialognon-dismissable tant que l'utilisateur n'a pas confirmé (Done ferme),useEffectqui dépend deupdated_atau lieu de l'identité de l'objet (évite la reset-loop documentée en commentaire). - Pure functions testables :
getDisplayName/getInitialsdans le domain entity, simples à unit-tester (et c'est ce qu'on devrait faire, cf. ci-dessous). - Test coverage solide : hooks (
useApiKeys,useLlmSettings) + adapters (axios, chatApi) + page (SettingsPage).
🔧 Améliorations suggérées
1. (Bloquant mineur) Tests manquants pour les pure functions du domain
src/domain/entities/auth/currentUser.ts exporte getDisplayName() et getInitials(). Ce sont des pure functions avec une matrice de cas non-triviale (4 niveaux de fallback pour le display name, split whitespace, fallback caractère unique, cas null/undefined/""). Aucun test direct — seul useCurrentUser est testé, et ces helpers sont utilisés dans 2 composants (Sidebar, ChatMessage). Vu que la fonction n'a aucune dépendance, c'est exactement le genre de truc qu'il faut unit-tester exhaustivement.
Cas à couvrir (a minima) :
getDisplayName(null) === ""getDisplayName({userId:"u1"}) === "u1"getDisplayName({userId:"u1", name:" Jane "}) === "Jane"(trim)getDisplayName({userId:"u1", username:"jdoe", name:""}) === "jdoe"(name vide → username)getDisplayName({userId:"u1", email:"alice@x.com"}) === "alice"getDisplayName({userId:"u1", name:"Jane Doe"}) === "Jane Doe"(espace interne préservé)getInitials("Jane Doe") === "JD"getInitials("jane") === "J"getInitials(" Jane Doe Smith ") === "JD"(multi-whitespace)getInitials("") === " "getInitials(" ") === " "
2. (Bloquant mineur) ChatMessage.tsx — fallback hardcodé "Y"
const userInitial = getDisplayName(profile).charAt(0).toUpperCase() || "Y";Le || "Y" est probablement un héritage du maquette (avatar "Y" pour "Yohan"). Maintenant que le username vient du backend, ce fallback est trompeur : si le user charge la page avant useCurrentUser (premier render), il verra "Y" → identité d'un autre user affichée par erreur. Trois options :
- Afficher
" "(commegetInitials("")retourne déjà" ") pour signaler "pas encore chargé". - Afficher un spinner/squelette discret.
- Au minimum, documenter le fallback en commentaire (c'est volontaire mais ambigu).
3. (Important) Sidebar.tsx — pas de gestion d'erreur si /me échoue
const { profile, isLoading } = useCurrentUser();Aucune branche sur isError. Si le backend renvoie 500 sur /api/v1/users/me, l'utilisateur reste bloqué avec initials = "…" et label = "Loading…" indéfiniment. C'est moins critique que la page Settings (qui affiche isError ailleurs), mais ça mérite au minimum :
- Un fallback
label = "Guest"/initials = "?"en cas d'erreur, - Ou un retry manuel discret (bouton "retry" à côté de l'avatar).
4. (Important) SSE 401 — err.status n'existe pas sur les erreurs fetch-event-source
onerror(err) {
if (err && typeof err === "object" && "status" in err && err.status === 401) {
redirectToSignIn();
}
...
}@microsoft/fetch-event-source ne propage pas la status HTTP dans son callback onerror par défaut — err est un Error sans .status. La branche est donc structurellement inatteignable avec cette lib. Pour gérer les 401 sur le SSE proprement, il faut soit :
- Passer par
fetchdirectement et inspecterresponse.statusdans un wrapper, - Hooker le
onopenqui reçoit leResponse(et lire.status), - Ou surcharger
onerrorpour intercepterTypeError/network errors et rediriger sur une heuristique (acceptable mais fragile).
À minima, ajouter un commentaire qui documente que cette branche n'est pas déclenchée aujourd'hui et pourquoi, ou corriger avec un wrapper sur onopen.
5. (Important) LlmSettingsCard — le form se reset après chaque updated_at, mais le password field reste vide
useEffect(() => {
const provider = settingsProvider ?? "openai";
reset({
provider,
base_url: settingsBaseUrl ?? PROVIDER_DEFAULTS.openai,
api_key: "", // toujours vide
});
setProviderDraft(provider);
}, [settingsProvider, settingsBaseUrl, settingsUpdatedAt, reset]);C'est cohérent (on ne veut pas pré-remplir le champ password), mais ça veut dire qu'à chaque updated_at change (ex: refetch manuel, autre onglet) le form se vide, ce qui est déroutant si l'user est en train de taper sa clé. Le commentaire est bon mais le comportement pourrait surprendre. Suggestion : ne trigger le reset que si !isUpserting && !isRemoving (pour ne pas casser un save en cours), ou ajouter un bouton "Discard changes" explicite.
6. (Mineur) oauth2Redirect.ts — SIGN_OUT_PATH exporté mais inutilisé
const SIGN_IN_PATH = "/oauth2/start";
const SIGN_OUT_PATH = "/oauth2/sign_out";SIGN_OUT_PATH est déclaré mais redirectToSignOut() hardcode "/oauth2/sign_out" en string literal. Soit l'utiliser, soit le retirer pour éviter le doublon.
7. (Mineur) mcpAxiosInstance.ts / ragAxiosInstance.ts — duplication de l'interceptor 401
Le bloc if (error.response?.status === 401) { redirectToSignIn(); } + wrapped.status est dupliqué 3 fois (apiClient, mcpApiClient, ragApiClient). Extraire un helper :
// infrastructure/api/createAuthInterceptor.ts
export function authErrorInterceptor(error: any) {
if (error?.response?.status === 401) redirectToSignIn();
if (error?.response) {
const detail = error.response.data?.detail || error.message;
const wrapped = new Error(detail) as Error & { status?: number };
wrapped.status = error.response.status;
return Promise.reject(wrapped);
}
return Promise.reject(error);
}Et l'utiliser : apiClient.interceptors.response.use(r => r, authErrorInterceptor). Évite la dérive entre les 3 instances (l'apiClient original n'a même pas le wrapped.status ajouté dans la PR — cf. point suivant).
8. (Bug) apiClient interceptor 401 n'enveloppe pas l'erreur
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
redirectToSignIn();
}
if (error.response) {
const detail = error.response.data?.detail || error.message;
const wrapped = new Error(detail) as Error & { status?: number };
return Promise.reject(wrapped); // <-- pas de .status = error.response.status
}
...Tandis que mcpApiClient et ragApiClient ont bien été mis à jour pour ajouter wrapped.status. Inconsistance : si un consumer dépend de err.status sur apiClient (cf. le SSE dans chatApi, ou les hooks), ça ne marche pas. À harmoniser.
9. (Mineur) useSettingsStore — migratePersisted peut être plus strict
function migratePersisted(state): Partial<SettingsState> {
const { llmProvider: _llm, apiKey: _key, ...rest } = state;
return rest as Partial<SettingsState>;
}Bien — strip des champs legacy. Mais aucune log pour dire "j'ai migré X users de l'ancien format". Recommandé : console.info (dev only) ou telemetry event quand state.llmProvider !== undefined est détecté à la lecture, pour mesurer combien de clients ont encore des données legacy. Donne de la visibilité sur la dette technique.
10. (Doc) README mentionne localStorage comme storage LLM settings alors que la PR le supprime
Le diff README semble OK (il mentionne bien PUT /api/v1/settings/llm maintenant), mais il y a encore une ligne dans ## Settings qui dit "localStorageonly" ? À vérifier dans la version finale. Sinon tout est à jour.
11. (Mineur) getInitials retourne " " (un espace) au lieu de "" — un peu surprenant
if (!displayName) return " ";Le commentaire explique que c'est pour le "loading state". Mais retourner un espace dans un avatar vide crée un caractère de hauteur visible. Préférer un retour explicite typé ("") et laisser le composant choisir son placeholder (getInitials reste pure, l'UI décide du fallback visuel). Test plus simple aussi.
12. (Mineur) Pas de test pour oauth2Redirect
C'est de la pure function avec un seul effet de bord (window.location.href = ...). Mockable trivialement avec vi.stubGlobal("window", {...}). Tests suggérés :
- Redirect vers
/oauth2/start?rd=<encoded current url>quand on est sur/foo. - No-op quand on est déjà sur
/oauth2/start. - No-op quand on est déjà sur
/oauth2/sign_out. rdcorrectement URL-encodé (avec?,&,#dans l'URL source).
📋 Récap
| Catégorie | Statut |
|---|---|
| Architecture hexagonale | ✅ Propre |
| Sécurité credentials (plaintext, cookie, masquage) | ✅ Bien |
| UX (data-od-id, dialogs, copy+warn) | ✅ Soigné |
| Migration de store | ✅ Bien |
| Tests hooks + adapters | ✅ Solide |
| Tests domain pure functions | ❌ Manquant |
| Tests oauth2Redirect | ❌ Manquant |
| SSE 401 redirect | |
| Inconsistance interceptors (apiClient.status) | 🐛 Bug |
| Duplication interceptors 401 | 🔧 Refactor suggéré |
Gestion erreur Sidebar /me fail |
🔧 À ajouter |
ChatMessage fallback "Y" |
🔧 À clarifier |
LlmSettingsCard reset on updated_at |
🔧 UX à valider |
SIGN_OUT_PATH inutilisé |
🧹 Cleanup |
Recommandation : commenter sans approbation sur les points 4 (SSE 401 inatteignable), 8 (apiClient inconsistent), et 1 (tests domain). Les autres sont discutables en post-merge mais idéalement avant.
(Note: en tant qu'auteur de la PR, je ne peux pas me mettre moi-même en Request Changes via l'API — je laisse ces points en commentaire pour décision.)
PR globalement très bien foutue, le découpage est propre et la doc README exhaustive. 👏
Context
Frontend for composables: credentials (cookie-based via oauth2-proxy), per-user LLM settings UI, per-user API keys management. Depends on composable-agents PR #39 (backend endpoints) + flux ticket 4 (oauth2-proxy in stack).
Changes
Quality gates
Breaking changes
Tests