From b6e0fccf7b007e3c7170424102673f9bdf63914c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Mon, 27 Jul 2026 13:27:27 +0200 Subject: [PATCH 1/3] feat: credentials, per-user LLM settings, API keys management, oauth2-proxy integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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= 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). --- README.md | 111 ++++++-- src/application/components/layout/Sidebar.tsx | 11 +- .../components/mcpServer/McpServerGrid.tsx | 21 +- .../components/settings/ApiKeysCard.tsx | 261 ++++++++++++++++++ .../components/settings/LlmSettingsCard.tsx | 240 ++++++++++++++++ .../components/ui/alert-dialog.tsx | 110 ++++++++ src/application/hooks/auth/useApiKeys.ts | 57 ++++ .../hooks/settings/useLlmSettings.ts | 56 ++++ src/application/pages/McpRegistryPage.tsx | 6 +- src/application/pages/SettingsPage.tsx | 63 ++--- src/application/stores/useSettingsStore.ts | 35 +-- src/domain/entities/auth/apiKey.ts | 30 ++ src/domain/entities/settings/llmSettings.ts | 26 ++ src/domain/ports/auth/apiKeyPort.ts | 16 ++ src/domain/ports/settings/settingsPort.ts | 17 ++ src/infrastructure/api/auth/apiKeyApi.ts | 25 ++ src/infrastructure/api/axiosInstance.ts | 5 + src/infrastructure/api/chat/chatApi.ts | 5 + src/infrastructure/api/mcpAxiosInstance.ts | 9 +- src/infrastructure/api/ragAxiosInstance.ts | 9 +- .../api/settings/settingsApi.ts | 27 ++ src/infrastructure/auth/oauth2Redirect.ts | 47 ++++ .../hooks/auth/useApiKeys.test.tsx | 102 +++++++ .../hooks/settings/useLlmSettings.test.tsx | 99 +++++++ .../components/agent/AgentConfigForm.test.tsx | 4 +- .../mcpServer/McpServerGrid.test.tsx | 4 +- .../infrastructure/api/auth/apiKeyApi.test.ts | 69 +++++ .../infrastructure/api/axiosInstances.test.ts | 146 ++++++++++ .../api/settings/settingsApi.test.ts | 85 ++++++ .../unit/pages/SettingsPage.apiKeys.test.tsx | 158 +++++++++++ tests/unit/pages/SettingsPage.llm.test.tsx | 152 ++++++++++ tests/unit/pages/SettingsPage.test.tsx | 62 ++++- tests/unit/stores/useSettingsStore.test.ts | 54 ++-- 33 files changed, 1968 insertions(+), 154 deletions(-) create mode 100644 src/application/components/settings/ApiKeysCard.tsx create mode 100644 src/application/components/settings/LlmSettingsCard.tsx create mode 100644 src/application/components/ui/alert-dialog.tsx create mode 100644 src/application/hooks/auth/useApiKeys.ts create mode 100644 src/application/hooks/settings/useLlmSettings.ts create mode 100644 src/domain/entities/auth/apiKey.ts create mode 100644 src/domain/entities/settings/llmSettings.ts create mode 100644 src/domain/ports/auth/apiKeyPort.ts create mode 100644 src/domain/ports/settings/settingsPort.ts create mode 100644 src/infrastructure/api/auth/apiKeyApi.ts create mode 100644 src/infrastructure/api/settings/settingsApi.ts create mode 100644 src/infrastructure/auth/oauth2Redirect.ts create mode 100644 tests/unit/application/hooks/auth/useApiKeys.test.tsx create mode 100644 tests/unit/application/hooks/settings/useLlmSettings.test.tsx create mode 100644 tests/unit/infrastructure/api/auth/apiKeyApi.test.ts create mode 100644 tests/unit/infrastructure/api/axiosInstances.test.ts create mode 100644 tests/unit/infrastructure/api/settings/settingsApi.test.ts create mode 100644 tests/unit/pages/SettingsPage.apiKeys.test.tsx create mode 100644 tests/unit/pages/SettingsPage.llm.test.tsx diff --git a/README.md b/README.md index 5e69d0c..63e7df2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ React frontend for interacting with the [composable-agents](https://github.com/s - **Thread history** -- Conversation threads grouped by agent in a sidebar - **RAG file browser** -- Browse MinIO folders and files with breadcrumb navigation and file metadata display. Create new folders (`CreateFolderDialog` triggered by the "New folder" button in the RAG browse tab) and delete files or folders via a trash-icon row action confirmed through a `ConfirmDeleteDialog`. Folder deletion is recursive and targets the MinIO prefix; file deletion targets a single object. Both calls hit the `POST /api/v1/files/folders` and `DELETE /api/v1/files` / `DELETE /api/v1/files/folders` endpoints exposed by `mcp-raganything`. Multi-file upload with drag-and-drop and folder upload support (see [File upload](#file-upload)). - **Tetris design system** -- Dark arcade visual language aligned with the Open Design `composable` maquette: magenta accent, Press Start 2P applied to all text (display, body, labels, buttons, nav, chat), block shadows, and `steps(2, end)` easing. Light theme toggle available from the Settings page. -- **Settings** -- A dedicated `/settings` page (4 cards: Theme, Chat, LLM Provider, Reset) backed by a persisted Zustand store and aligned to the Open Design maquette via `data-od-id` QA attributes. +- **Settings** -- A dedicated `/settings` page (5 cards: Theme, Typography, LLM Provider, API Keys, Reset) backed by a persisted Zustand store for appearance and the composable-agents backend for per-user credentials, aligned to the Open Design maquette via `data-od-id` QA attributes. A "Sign out" button in the page header clears the oauth2-proxy cookie session. ## Tech Stack @@ -50,6 +50,7 @@ The app shell faithfully reproduces the maquette: a 220px left `Sidebar` (brand, - [Bun](https://bun.sh/) >= 1.0 - [composable-agents](https://github.com/soludev/bricks/composable-agents) API running on port 8010 - [mcp-raganything](https://github.com/soludev/bricks/mcp-raganything) API running on port 8020 +- [oauth2-proxy](https://oauth2-proxy.github.io/) fronting the backend(s), issuing a cookie session shared across the `.soludev.tech` subdomain (see [Authentication](#authentication)) ## Installation @@ -67,26 +68,58 @@ cp public/config.example.json public/config.json **`public/config.json`** - Application configuration: -| Field | Type | Description | -| --------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | -| `apiBaseUrl` | `string` | composable-agents API URL (e.g., `http://localhost:8010`) | -| `ragApiBaseUrl` | `string` (optional) | RAG API URL for MinIO file browsing (e.g., `http://localhost:8020`). Defaults to `apiBaseUrl` if not set. | -| `wsBaseUrl` | `string` | WebSocket URL for streaming (e.g., `ws://localhost:8010`) | +| Field | Type | Description | +| ----------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `apiBaseUrl` | `string` | composable-agents API URL (e.g., `http://localhost:8010`) | +| `ragApiBaseUrl` | `string` (optional) | RAG API URL for MinIO file browsing (e.g., `http://localhost:8020`). Defaults to `apiBaseUrl` if not set. | +| `wsBaseUrl` | `string` | WebSocket URL for streaming (e.g., `ws://localhost:8010`) | +| `mcpApiBaseUrl` | `string` (optional) | MCP API base URL (e.g., `http://localhost:8030`). Defaults to empty string when no MCP registry is deployed. | The config is validated with Zod on startup. Invalid configuration will show an error toast. **Note:** `config.json` is gitignored. Use `config.example.json` as a template. +## Authentication + +Authentication is delegated to **[oauth2-proxy](https://oauth2-proxy.github.io/)** using a cookie session shared across the `.soludev.tech` subdomain. There is no client-side SDK or token in `localStorage`; the browser sends the cookie with every request and oauth2-proxy validates it server-side before forwarding to the backend. + +### Cookie-based credentials (`withCredentials`) + +All axios instances — `apiClient` (composable-agents), `ragApiClient` (mcp-raganything), and `mcpApiClient` (MCP registry) — are configured with `withCredentials: true`, so the oauth2-proxy session cookie travels with every cross-subdomain request. The SSE stream (`fetchEventSource` in `chatApi.ts`) opens with `credentials: "include"` for the same reason. + +### 401 → sign-in redirect + +Each axios instance has a response interceptor that, on a `401 Unauthorized`, calls `redirectToSignIn()` from `src/infrastructure/auth/oauth2Redirect.ts`. This navigates the browser to: + +``` +/oauth2/start?rd= +``` + +The `rd` parameter bounces the user back to the page they were on once oauth2-proxy has re-established the session. A guard (`isOnAuthPage()`) prevents infinite redirect loops: if the current path is already `/oauth2/start` or `/oauth2/sign_out`, the redirect is a no-op. The SSE handler in `chatApi.ts` performs the same check when the event source returns a `401`. + +### Sign out + +The "Sign out" button in the `/settings` page header calls `redirectToSignOut()`, which navigates to `/oauth2/sign_out` and clears the cookie session. There is no client-side logout logic — the backend/oauth2-proxy owns session termination. + ## Settings -The `/settings` page groups user preferences into four cards, all wired to the persisted `useSettingsStore` (Zustand, `localStorage["composable-ui-settings"]`). The page and its controls carry `data-od-id` attributes for QA parity with the Open Design `composable` maquette. +The `/settings` page groups user preferences into five cards. Appearance settings (Theme, Typography, Reset) are wired to the persisted `useSettingsStore` (Zustand, `localStorage["composable-ui-settings"]`); credential cards (LLM Provider, API Keys) persist to the composable-agents backend via per-user endpoints. The page and its controls carry `data-od-id` attributes for QA parity with the Open Design `composable` maquette. - **Theme** -- Accent color picker (hex input that live-updates the `--accent` CSS variable), surface shade select, and the dark/light mode switch (the toggle previously in the header now lives here). -- **Chat** -- Message font size (range slider, 10-18px) and message font family (`Press Start 2P` / Monospace / Mono / Georgia). -- **LLM Provider** -- Provider select (`anthropic` / `openai` / `google` / `mistral` / `local` / `ollama`) and an API key field (password type, stored in `localStorage` only — never sent to the backend). -- **Reset** -- "Reset to Defaults" button that restores all settings to their initial values. - -The theme toggle is now owned by `useSettingsStore`; the legacy `useThemeStore` (`composable-ui-theme` key) is still present for boot-time anti-FOUC but is no longer driven by the UI and is slated for removal in a future refactor. +- **Typography** -- Message font size (range slider, 10-18px) and message font family (`Press Start 2P` / Monospace / Mono / Georgia). +- **LLM Provider** -- Provider select (`anthropic` / `openai` / `google` / `mistral` / `local` / `ollama`), base URL, and API key. Persisted to the backend (replaces the legacy `localStorage`-only `apiKey`/`llmProvider`): + - `GET /api/v1/settings/llm` — fetch the current user's settings; the API key is returned **masked** (e.g. `sk-***1234`), never in plaintext. + - `PUT /api/v1/settings/llm` — upsert `{ provider, base_url, api_key }`; the plaintext key is sent once over HTTPS and the backend stores only a hash. + - `DELETE /api/v1/settings/llm` — remove the current user's LLM settings. + - Implemented by `LlmSettingsCard` → `useLlmSettings` hook → `settingsApi` adapter (`ISettingsPort`). The `LlmSettings` entity lives in `src/domain/entities/settings/llmSettings.ts`. +- **API Keys** -- Per-user personal access tokens (used as the secondary `X-API-Key` authentication mechanism alongside the oauth2-proxy cookie). A new "API Keys" card lists existing keys, lets you create a new one, and revoke any key: + - `GET /api/v1/api-keys` — list the current user's keys (`id`, `name`, `key_prefix`, `created_at`, `last_used_at`, `revoked_at`). + - `POST /api/v1/api-keys` with `{ name }` — create a key. The **plaintext is returned exactly once** in the response; `ApiKeysCard` shows it with a copy button and a "you won't see this again" warning. It is never persisted client-side. + - `DELETE /api/v1/api-keys/{id}` — revoke a key (confirmed via an `AlertDialog`). + - Implemented by `ApiKeysCard` → `useApiKeys` hook → `apiKeyApi` adapter (`IApiKeyPort`). The `ApiKeyView` / `CreatedApiKey` / `CreateApiKeyInput` entities live in `src/domain/entities/auth/apiKey.ts`. +- **Reset** -- "Reset to Defaults" button that restores the appearance settings (Theme + Typography) to their initial values. This does **not** touch backend-stored LLM settings or API keys. + +The theme toggle is owned by `useSettingsStore`; the legacy `useThemeStore` (`composable-ui-theme` key) is still present for boot-time anti-FOUC but is no longer driven by the UI and is slated for removal in a future refactor. The legacy `apiKey`/`llmProvider` keys previously written to `localStorage` by `useSettingsStore` have been superseded by the backend-persisted LLM settings and are no longer used. ## Running @@ -104,12 +137,33 @@ bun run preview ## Testing ```bash -bun run test # Run all tests +bun run test # Run all tests (119 files, 780 tests passing) bun run test:watch # Watch mode bun run test:ui # Vitest UI bun run test:coverage # With coverage report ``` +The credential/auth flow is covered by: + +- `tests/unit/infrastructure/api/axiosInstances.test.ts` — `withCredentials: true` and 401 → `redirectToSignIn` on all three axios instances. +- `tests/unit/infrastructure/api/auth/` — `apiKeyApi` adapter (list / create / revoke). +- `tests/unit/infrastructure/api/settings/` — `settingsApi` adapter (get / upsert / delete LLM settings). +- `tests/unit/application/hooks/auth/` — `useApiKeys` hook. +- `tests/unit/application/hooks/settings/` — `useLlmSettings` hook. +- `tests/unit/pages/SettingsPage.test.tsx` and `SettingsPage.apiKeys.test.tsx` — page-level coverage of the LLM Provider and API Keys cards. + +### Build verification + +```bash +bun run build # Vite production build (must succeed with no type errors) +``` + +### QA via the Docker stack + +The full stack (composable-agents + mcp-raganything + composable-ui + oauth2-proxy) can be brought up with the Docker Compose setup in `soludev-compose-apps/bricks`. The UI is served on **port 8030**. + +> **oauth2-proxy e2e limitation:** In local QA without a real upstream IdP and the `.soludev.tech` cookie domain, the cookie session cannot be fully exercised. The 401 → `/oauth2/start` redirect and `/oauth2/sign_out` flow are therefore validated against a deployed environment where oauth2-proxy is reachable; locally, hitting a protected endpoint will simply bounce to `/oauth2/start` (expected). The per-user LLM settings and API keys endpoints can be exercised directly against the backend when running outside the proxy. + ## Linting and Formatting ```bash @@ -149,21 +203,30 @@ src/ domain/ # Business entities and port interfaces entities/ agent/ # AgentConfig, AgentConfigMetadata, McpServerConfig + auth/ # ApiKeyView, CreatedApiKey, CreateApiKeyInput (per-user API keys) chat/ # Message, Thread, ChatRequest, TraceEvent (6 types: human_message, ai_message, thinking, content, tool_call, tool_result), ThreadHistory config/ # AppConfig (Zod-validated) rag/ # FileEntry, FolderEntry + settings/ # LlmSettings, UpsertLlmSettingsInput (per-user LLM provider settings) ports/ agent/agentPort.ts # Agent repository interface + auth/apiKeyPort.ts # API key repository interface (IApiKeyPort) chat/chatPort.ts # Chat repository interface config/configRepository.ts # Config repository interface rag/ragFilePort.ts # RAG file port interface - infrastructure/ # External adapters (API clients, config) + settings/settingsPort.ts # LLM settings repository interface (ISettingsPort) + infrastructure/ # External adapters (API clients, config, auth helpers) api/ agent/agentApi.ts # Agent API adapter (axios) - chat/chatApi.ts # Chat API adapter (axios + SSE, emits TraceEvent) + auth/apiKeyApi.ts # API key adapter (IApiKeyPort → /api/v1/api-keys) + chat/chatApi.ts # Chat API adapter (axios + SSE, credentials: "include", emits TraceEvent) rag/ragApi.ts # RAG API adapter (axios) - axiosInstance.ts # Shared axios instance - ragAxiosInstance.ts # Separate axios client for RAG API + axiosInstance.ts # Shared axios instance (withCredentials + 401 → /oauth2/start) + ragAxiosInstance.ts # RAG axios client (withCredentials + 401 redirect) + mcpAxiosInstance.ts # MCP axios client (withCredentials + 401 redirect) + settings/settingsApi.ts # LLM settings adapter (ISettingsPort → /api/v1/settings/llm) + auth/ + oauth2Redirect.ts # redirectToSignIn / redirectToSignOut helpers (loop-guarded) config/ configRepositoryInstance.ts # Singleton config repository fileConfigRepository.ts # File-based config implementation @@ -173,22 +236,24 @@ src/ chat/ # ChatInput, ChatMessage, MessageList, HITLReviewPanel, ThinkingBlock, ToolCallBadge, ToolResultBlock, SubagentPanel layout/ # AppShell, Sidebar (System section + Settings nav), MainHeader (mobile menu + title only), ThreadSidebar rag/ # BreadcrumbBar, FileList, FileRow, FolderRow, CreateFolderDialog, ConfirmDeleteDialog, FileContentPanel, IndexActionMenu, QueryPanel, QueryResults, QueryOptions, RagTabBar, UploadButton, WorkspaceSelector - settings/ # SettingsPage cards (Theme, Chat, LLMProvider, Reset) + settings/ # LlmSettingsCard, ApiKeysCard (backend-persisted credential cards) shared/ # SegmentedToggle, StatusBadge, ToolTag - ui/ # shadcn/ui primitives (Tetris-themed) + ui/ # shadcn/ui primitives (Tetris-themed) incl. alert-dialog for revoke confirm hooks/ agent/ # useAgents, useCreateAgent, useDeleteAgent, useUpdateAgent, useAgentConfig + auth/ # useApiKeys (list / create / revoke) chat/ # useThreads, useCreateThread, useDeleteThread, useThreadHistory, useSendMessage, useStreamChat - config/ # useConfig + config/ # useConfig rag/ # useFolders, useFiles, useReadFile, useUploadFile, useCreateFolder, useDeleteFile, useDeleteFolder, useClassicalIndexFile, useClassicalIndexFolder, useClassicalQuery + settings/ # useLlmSettings (get / upsert / delete) pages/ AgentsPage.tsx # /agents route ChatPage.tsx # /chat/:threadId? route RagPage.tsx # /rag route - SettingsPage.tsx # /settings route (Theme, Chat, LLM Provider, Reset cards) + SettingsPage.tsx # /settings route (Theme, Typography, LLM Provider, API Keys, Reset cards + Sign out button) stores/ useChatStore.ts # Zustand store for chat state - useSettingsStore.ts # Zustand store for Settings page (theme, accent, chat, LLM, persisted to "composable-ui-settings") + useSettingsStore.ts # Zustand store for Settings page (theme, accent, typography — appearance only; credentials now backend-persisted) useThemeStore.ts # Legacy Zustand store for theme (dark/light) + persistence (still used for boot-time anti-FOUC) useSidebarStore.ts # Zustand store for mobile sidebar open/close public/ @@ -208,7 +273,7 @@ tests/ | `/agents` | AgentsPage | List, create, view, and delete agents | | `/chat/:threadId?` | ChatPage | Chat with agents, streaming responses, HITL validation | | `/rag` | RagPage | Browse MinIO folders and files with breadcrumb navigation | -| `/settings` | SettingsPage | Theme, chat, LLM provider, and reset preferences (persisted to `localStorage`) | +| `/settings` | SettingsPage | Theme, typography, LLM provider (backend-persisted), API keys (backend-persisted), reset, and Sign out | ## Agent Configuration diff --git a/src/application/components/layout/Sidebar.tsx b/src/application/components/layout/Sidebar.tsx index c44cdd7..8fb12e9 100644 --- a/src/application/components/layout/Sidebar.tsx +++ b/src/application/components/layout/Sidebar.tsx @@ -1,6 +1,15 @@ import { useEffect } from "react"; import { NavLink } from "react-router-dom"; -import { BookOpen, Bot, Database, MessagesSquare, Server, Settings, Sparkles, X } from "lucide-react"; +import { + BookOpen, + Bot, + Database, + MessagesSquare, + Server, + Settings, + Sparkles, + X, +} from "lucide-react"; import { cn } from "@/application/lib/utils"; import { useSidebarStore } from "@/application/stores/useSidebarStore"; diff --git a/src/application/components/mcpServer/McpServerGrid.tsx b/src/application/components/mcpServer/McpServerGrid.tsx index 86c911b..873860d 100644 --- a/src/application/components/mcpServer/McpServerGrid.tsx +++ b/src/application/components/mcpServer/McpServerGrid.tsx @@ -24,10 +24,7 @@ export default function McpServerGrid({ if (isLoading) { return ( -
+
diff --git a/src/application/components/settings/ApiKeysCard.tsx b/src/application/components/settings/ApiKeysCard.tsx new file mode 100644 index 0000000..84d48e9 --- /dev/null +++ b/src/application/components/settings/ApiKeysCard.tsx @@ -0,0 +1,261 @@ +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Copy, Plus, Trash2 } from "lucide-react"; +import { Badge } from "@/application/components/ui/badge"; +import { Button } from "@/application/components/ui/button"; +import { Input } from "@/application/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/application/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/application/components/ui/dialog"; +import type { ApiKeyView, CreatedApiKey } from "@/domain/entities/auth/apiKey"; +import { useApiKeys } from "@/application/hooks/auth/useApiKeys"; + +function formatDate(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 19).replace("T", " "); +} + +interface ApiKeyRowProps { + readonly apiKey: ApiKeyView; + readonly onRevoke: (id: string) => void; +} + +function ApiKeyRow({ apiKey, onRevoke }: ApiKeyRowProps) { + const [open, setOpen] = useState(false); + const isRevoked = Boolean(apiKey.revoked_at); + + return ( +
  • +
    + {apiKey.name} + {apiKey.key_prefix} +
    +
    + + created: {formatDate(apiKey.created_at)} + + + last used: {formatDate(apiKey.last_used_at)} + + {isRevoked ? ( + Revoked + ) : ( + Active + )} + {!isRevoked ? ( + + ) : null} +
    + + + + Revoke API key + + Are you sure you want to revoke the key {apiKey.name} ( + {apiKey.key_prefix})? This action cannot be undone. Any client using this key will + immediately lose access. + + + + Cancel + { + setOpen(false); + onRevoke(apiKey.id); + }} + > + Confirm revoke + + + + +
  • + ); +} + +interface CreatedKeyDialogProps { + readonly created: CreatedApiKey | null; + readonly onClose: () => void; +} + +function CreatedKeyDialog({ created, onClose }: CreatedKeyDialogProps) { + const open = created !== null; + return ( + !o && onClose()}> + + + API key created + + Copy the key below now — you won't see it again. Store it securely. + + + {created ? ( +
    + + {created.plaintext} + +

    + ⚠ You won't see this again. +

    +
    + + +
    +
    + ) : null} +
    +
    + ); +} + +export interface ApiKeysCardProps { + readonly odId?: string; +} + +export default function ApiKeysCard({ odId = "settings-api-keys" }: ApiKeysCardProps) { + const { keys, isLoading, isError, error, create, revoke, isCreating } = useApiKeys(); + const [newName, setNewName] = useState(""); + const [created, setCreated] = useState(null); + + const sortedKeys = useMemo( + () => [...keys].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)), + [keys], + ); + + async function onCreate() { + const name = newName.trim(); + if (!name) { + toast.error("Key name is required"); + return; + } + try { + const result = await create({ name }); + setCreated(result); + setNewName(""); + toast.success(`Key "${name}" created`); + } catch (err) { + toast.error((err as Error).message ?? "Failed to create key"); + } + } + + async function onRevoke(id: string) { + try { + await revoke(id); + toast.success("Key revoked"); + } catch (err) { + toast.error((err as Error).message ?? "Failed to revoke key"); + } + } + + return ( +
    +
    +

    API Keys

    +
    +
    +

    + Personal access tokens used as the X-API-Key header for API automation. + Plaintext is shown once at creation. +

    + +
    { + e.preventDefault(); + void onCreate(); + }} + > +
    + + setNewName(e.target.value)} + /> +
    + +
    + + {isLoading ? ( +

    + Loading API keys… +

    + ) : sortedKeys.length === 0 ? ( +

    No API keys yet.

    + ) : ( +
      + {sortedKeys.map((k) => ( + + ))} +
    + )} + + {isError ? ( +

    + {error?.message ?? "Failed to load API keys"} +

    + ) : null} +
    + + setCreated(null)} /> +
    + ); +} diff --git a/src/application/components/settings/LlmSettingsCard.tsx b/src/application/components/settings/LlmSettingsCard.tsx new file mode 100644 index 0000000..19d2e71 --- /dev/null +++ b/src/application/components/settings/LlmSettingsCard.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Button } from "@/application/components/ui/button"; +import { Input } from "@/application/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/application/components/ui/select"; +import { useLlmSettings } from "@/application/hooks/settings/useLlmSettings"; + +const LLM_PROVIDERS = ["openai", "openrouter", "litellm", "custom"] as const; +type LlmProvider = (typeof LLM_PROVIDERS)[number]; + +const PROVIDER_DEFAULTS: Readonly> = { + openai: "https://api.openai.com/v1", + openrouter: "https://openrouter.ai/api/v1", + litellm: "", + custom: "", +}; + +const PROVIDERS = LLM_PROVIDERS; + +const LlmSchema = z.object({ + provider: z.enum(LLM_PROVIDERS), + base_url: z.string().url().or(z.literal("")), + api_key: z.string().min(1, "API key is required"), +}); + +type LlmFormValues = z.infer; + +export interface LlmSettingsCardProps { + readonly odId?: string; +} + +export default function LlmSettingsCard({ odId = "settings-llm" }: LlmSettingsCardProps) { + const { settings, isLoading, isError, error, upsert, remove, isUpserting, isRemoving } = + useLlmSettings(); + const [providerDraft, setProviderDraft] = useState("openai"); + + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(LlmSchema), + defaultValues: { + provider: "openai", + base_url: PROVIDER_DEFAULTS.openai, + api_key: "", + }, + mode: "onSubmit", + }); + + // Re-sync the form whenever the backend settings change (initial load or refetch). + // Depend on primitive values — NOT on the `settings` object identity, which would + // change every render and cause a reset → re-render loop. `updated_at` is the + // canonical "settings changed" signal from the backend. + const settingsProvider = settings?.provider as LlmProvider | undefined; + const settingsBaseUrl = settings?.base_url; + const settingsUpdatedAt = settings?.updated_at; + useEffect(() => { + const provider = settingsProvider ?? "openai"; + reset({ + provider, + base_url: settingsBaseUrl ?? PROVIDER_DEFAULTS.openai, + api_key: "", + }); + setProviderDraft(provider); + }, [settingsProvider, settingsBaseUrl, settingsUpdatedAt, reset]); + + const baseUrl = watch("base_url"); + + function onProviderChange(value: LlmProvider) { + setProviderDraft(value); + setValue("provider", value); + const next = PROVIDER_DEFAULTS[value]; + const isDefaultUrl = Object.values(PROVIDER_DEFAULTS).includes(baseUrl); + if (next && (!baseUrl || isDefaultUrl)) { + setValue("base_url", next); + } + } + + async function onSubmit(values: LlmFormValues) { + try { + await upsert({ + provider: values.provider, + base_url: values.base_url, + api_key: values.api_key, + }); + toast.success("LLM settings saved"); + reset({ ...values, api_key: "" }); + } catch (err) { + toast.error((err as Error).message ?? "Failed to save LLM settings"); + } + } + + async function onDelete() { + try { + await remove(); + toast.success("LLM settings deleted"); + reset({ provider: "openai", base_url: PROVIDER_DEFAULTS.openai, api_key: "" }); + } catch (err) { + toast.error((err as Error).message ?? "Failed to delete LLM settings"); + } + } + + if (isLoading) { + return ( +
    +
    +

    LLM Provider

    +
    +
    +

    + Loading LLM settings… +

    +
    +
    + ); + } + + return ( +
    +
    +

    LLM Provider

    +
    +
    +
    + + +
    + +
    + + + {errors.base_url ? ( +

    {errors.base_url.message}

    + ) : null} +
    + +
    + + + {errors.api_key ? ( +

    {errors.api_key.message}

    + ) : null} + {settings?.api_key_masked ? ( +

    + Current key: {settings.api_key_masked} +

    + ) : ( +

    + Stored encrypted on the server. Leave blank to keep the existing key. +

    + )} +
    + + {isError ? ( +

    + {error?.message ?? "Failed to load LLM settings"} +

    + ) : null} + +
    + + {settings ? ( + + ) : null} +
    +
    +
    + ); +} diff --git a/src/application/components/ui/alert-dialog.tsx b/src/application/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..c5c1c32 --- /dev/null +++ b/src/application/components/ui/alert-dialog.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import { cn } from "@/application/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
    +); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
    +); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogTrigger, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/application/hooks/auth/useApiKeys.ts b/src/application/hooks/auth/useApiKeys.ts new file mode 100644 index 0000000..20c94fc --- /dev/null +++ b/src/application/hooks/auth/useApiKeys.ts @@ -0,0 +1,57 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiKeyApi } from "@/infrastructure/api/auth/apiKeyApi"; +import type { ApiKeyView, CreatedApiKey, CreateApiKeyInput } from "@/domain/entities/auth/apiKey"; + +const QUERY_KEY = ["api-keys"] as const; + +export interface UseApiKeysResult { + readonly keys: ApiKeyView[]; + readonly isLoading: boolean; + readonly isError: boolean; + readonly error: Error | null; + readonly create: (input: CreateApiKeyInput) => Promise; + readonly revoke: (id: string) => Promise; + readonly isCreating: boolean; + readonly isRevoking: boolean; +} + +/** + * Application hook — bridges the SettingsPage with the API key port. + * + * Lists the user's keys on mount and exposes create/revoke mutations that + * invalidate the list after each change. The plaintext returned on create is + * captured once by the caller (the page) — it is never persistently stored. + */ +export function useApiKeys(): UseApiKeysResult { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: QUERY_KEY, + queryFn: () => apiKeyApi.list(), + }); + + const createMutation = useMutation({ + mutationFn: (input: CreateApiKeyInput) => apiKeyApi.create(input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: (id: string) => apiKeyApi.revoke(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }, + }); + + return { + keys: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + create: createMutation.mutateAsync, + revoke: revokeMutation.mutateAsync, + isCreating: createMutation.isPending, + isRevoking: revokeMutation.isPending, + }; +} diff --git a/src/application/hooks/settings/useLlmSettings.ts b/src/application/hooks/settings/useLlmSettings.ts new file mode 100644 index 0000000..2e7f4d1 --- /dev/null +++ b/src/application/hooks/settings/useLlmSettings.ts @@ -0,0 +1,56 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { settingsApi } from "@/infrastructure/api/settings/settingsApi"; +import type { LlmSettings, UpsertLlmSettingsInput } from "@/domain/entities/settings/llmSettings"; + +const QUERY_KEY = ["settings", "llm"] as const; + +export interface UseLlmSettingsResult { + readonly settings: LlmSettings | null; + readonly isLoading: boolean; + readonly isError: boolean; + readonly error: Error | null; + readonly upsert: (input: UpsertLlmSettingsInput) => Promise; + readonly remove: () => Promise; + readonly isUpserting: boolean; + readonly isRemoving: boolean; +} + +/** + * Application hook — bridges the SettingsPage with the LLM settings port. + * + * Fetches the current settings on mount (TanStack Query cache) and exposes + * `upsert`/`remove` mutations that update the cached value on success. + */ +export function useLlmSettings(): UseLlmSettingsResult { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: QUERY_KEY, + queryFn: () => settingsApi.getLlmSettings(), + }); + + const upsertMutation = useMutation({ + mutationFn: (input: UpsertLlmSettingsInput) => settingsApi.upsertLlmSettings(input), + onSuccess: (data) => { + queryClient.setQueryData(QUERY_KEY, data); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => settingsApi.deleteLlmSettings(), + onSuccess: () => { + queryClient.setQueryData(QUERY_KEY, null); + }, + }); + + return { + settings: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + upsert: upsertMutation.mutateAsync, + remove: deleteMutation.mutateAsync, + isUpserting: upsertMutation.isPending, + isRemoving: deleteMutation.isPending, + }; +} diff --git a/src/application/pages/McpRegistryPage.tsx b/src/application/pages/McpRegistryPage.tsx index 12a0f80..5b285e2 100644 --- a/src/application/pages/McpRegistryPage.tsx +++ b/src/application/pages/McpRegistryPage.tsx @@ -74,11 +74,7 @@ export default function McpRegistryPage() {
    - +
    {createDialogOpen && ( diff --git a/src/application/pages/SettingsPage.tsx b/src/application/pages/SettingsPage.tsx index 51c3fb6..a16e11f 100644 --- a/src/application/pages/SettingsPage.tsx +++ b/src/application/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { LogOut } from "lucide-react"; import { Button } from "@/application/components/ui/button"; import { Input } from "@/application/components/ui/input"; import { Label } from "@/application/components/ui/label"; @@ -12,8 +13,11 @@ import { } from "@/application/components/ui/select"; import { Switch } from "@/application/components/ui/switch"; import AppShell from "@/application/components/layout/AppShell"; +import LlmSettingsCard from "@/application/components/settings/LlmSettingsCard"; +import ApiKeysCard from "@/application/components/settings/ApiKeysCard"; import { useSettingsStore } from "@/application/stores/useSettingsStore"; import { hexToHue, hueToHex, isValidHex } from "@/application/lib/color"; +import { redirectToSignOut } from "@/infrastructure/auth/oauth2Redirect"; function SettingsCard({ odId, @@ -55,15 +59,11 @@ export default function SettingsPage() { const surface = useSettingsStore((s) => s.surface); const chatFontSize = useSettingsStore((s) => s.chatFontSize); const chatFontFamily = useSettingsStore((s) => s.chatFontFamily); - const llmProvider = useSettingsStore((s) => s.llmProvider); - const apiKey = useSettingsStore((s) => s.apiKey); const theme = useSettingsStore((s) => s.theme); const setAccent = useSettingsStore((s) => s.setAccent); const setSurface = useSettingsStore((s) => s.setSurface); const setChatFontSize = useSettingsStore((s) => s.setChatFontSize); const setChatFontFamily = useSettingsStore((s) => s.setChatFontFamily); - const setLlmProvider = useSettingsStore((s) => s.setLlmProvider); - const setApiKey = useSettingsStore((s) => s.setApiKey); const toggleTheme = useSettingsStore((s) => s.toggleTheme); const resetToDefaults = useSettingsStore((s) => s.resetToDefaults); @@ -77,10 +77,19 @@ export default function SettingsPage() { return (
    -
    +

    - Customize the console appearance, chat readability, and LLM provider. + Customize the console appearance, chat readability, per-user LLM provider and personal + API keys. Credentials are managed by oauth2-proxy.

    +
    @@ -203,46 +212,14 @@ export default function SettingsPage() {
    - -
    - Provider - -
    -
    - API Key - setApiKey(e.target.value)} - /> -

    - Stored locally in this browser session only. -

    -
    -
    + + +

    - Restore the default appearance and provider settings. + Restore the default appearance settings. This does not affect your backend-stored LLM + settings or API keys.

    diff --git a/src/application/components/layout/Sidebar.tsx b/src/application/components/layout/Sidebar.tsx index 8fb12e9..7fc5333 100644 --- a/src/application/components/layout/Sidebar.tsx +++ b/src/application/components/layout/Sidebar.tsx @@ -12,6 +12,8 @@ import { } from "lucide-react"; import { cn } from "@/application/lib/utils"; import { useSidebarStore } from "@/application/stores/useSidebarStore"; +import { useCurrentUser } from "@/application/hooks/auth/useCurrentUser"; +import { getDisplayName, getInitials } from "@/domain/entities/auth/currentUser"; interface NavItem { to: string; @@ -91,13 +93,18 @@ function SidebarNav() { } function SidebarFooter() { + const { profile, isLoading } = useCurrentUser(); + const displayName = getDisplayName(profile); + const initials = isLoading ? "…" : getInitials(displayName); + const label = isLoading ? "Loading…" : displayName; + return (
    - YH + {initials}
    -
    Yohan
    +
    {label}
    Operator
    diff --git a/src/application/hooks/auth/useCurrentUser.ts b/src/application/hooks/auth/useCurrentUser.ts new file mode 100644 index 0000000..4f2b37a --- /dev/null +++ b/src/application/hooks/auth/useCurrentUser.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { userApi } from "@/infrastructure/api/auth/userApi"; +import type { UserProfile } from "@/domain/entities/auth/currentUser"; + +const QUERY_KEY = ["current-user"] as const; + +export interface UseCurrentUserResult { + readonly profile: UserProfile | null; + readonly isLoading: boolean; + readonly isError: boolean; + readonly error: Error | null; +} + +/** + * Application hook — bridges the UI with the current-user port. + * + * Fetches the authenticated user's profile once per session (the profile does + * not change without a re-authentication, so `staleTime: Infinity` keeps the + * cached result fresh for the whole session and avoids refetches on window + * focus). + */ +export function useCurrentUser(): UseCurrentUserResult { + const query = useQuery({ + queryKey: QUERY_KEY, + queryFn: () => userApi.me(), + staleTime: Infinity, + retry: false, + }); + + return { + profile: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + }; +} \ No newline at end of file diff --git a/src/domain/entities/auth/currentUser.ts b/src/domain/entities/auth/currentUser.ts new file mode 100644 index 0000000..9eefa8f --- /dev/null +++ b/src/domain/entities/auth/currentUser.ts @@ -0,0 +1,50 @@ +/** + * Current-user profile entity — public projection of the authenticated user + * returned by `GET /api/v1/users/me`. + * + * The optional fields (`email`, `name`, `username`) are populated from the JWT + * claims when the user authenticated via oauth2-proxy (Logto). For API-key + * auth only `userId` is available and the optional fields are `null`. + */ + +export interface UserProfile { + readonly userId: string; + readonly email?: string | null; + readonly name?: string | null; + readonly username?: string | null; +} + +/** + * Best-effort display name for the current user. + * + * Priority: `name` -> `username` -> local part of `email` -> `userId`. + * Never returns an empty string. + */ +export function getDisplayName(profile: UserProfile | null | undefined): string { + if (!profile) return ""; + if (profile.name && profile.name.trim()) return profile.name.trim(); + if (profile.username && profile.username.trim()) return profile.username.trim(); + if (profile.email && profile.email.trim()) { + const localPart = profile.email.split("@")[0]; + if (localPart) return localPart; + } + return profile.userId; +} + +/** + * Up to two uppercase initials derived from the display name. + * + * Takes the first letter of the first two whitespace-separated words + * (e.g. "Jane Doe" -> "JD", "jane" -> "J"). Falls back to the first + * character of the display name, then to a single space when no display + * name is available (loading state). + */ +export function getInitials(displayName: string): string { + if (!displayName) return " "; + const words = displayName.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return " "; + const first = words[0].charAt(0).toUpperCase(); + if (words.length === 1) return first; + const second = words[1].charAt(0).toUpperCase(); + return (first + second).slice(0, 2); +} \ No newline at end of file diff --git a/src/domain/ports/auth/currentUserPort.ts b/src/domain/ports/auth/currentUserPort.ts new file mode 100644 index 0000000..1f43ef4 --- /dev/null +++ b/src/domain/ports/auth/currentUserPort.ts @@ -0,0 +1,11 @@ +import type { UserProfile } from "@/domain/entities/auth/currentUser"; + +/** + * Port for retrieving the authenticated user's profile. + * + * Implemented by `userApi` and consumed by the `useCurrentUser` hook. + */ +export interface ICurrentUserPort { + /** Returns the profile of the authenticated user (`GET /api/v1/users/me`). */ + me(): Promise; +} \ No newline at end of file diff --git a/src/infrastructure/api/auth/userApi.ts b/src/infrastructure/api/auth/userApi.ts new file mode 100644 index 0000000..bccbfd1 --- /dev/null +++ b/src/infrastructure/api/auth/userApi.ts @@ -0,0 +1,14 @@ +import type { UserProfile } from "@/domain/entities/auth/currentUser"; +import type { ICurrentUserPort } from "@/domain/ports/auth/currentUserPort"; +import { apiClient } from "@/infrastructure/api/axiosInstance"; + +/** + * Infrastructure adapter — implements ICurrentUserPort against the + * composable-agents backend (`/api/v1/users/me`). + */ +export const userApi: ICurrentUserPort = { + async me(): Promise { + const response = await apiClient.get("/api/v1/users/me"); + return response.data; + }, +}; \ No newline at end of file diff --git a/tests/unit/components/layout/Sidebar.test.tsx b/tests/unit/components/layout/Sidebar.test.tsx index af8fc6c..c4bc058 100644 --- a/tests/unit/components/layout/Sidebar.test.tsx +++ b/tests/unit/components/layout/Sidebar.test.tsx @@ -1,9 +1,28 @@ import { screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderWithProviders } from "../../../utils/render"; import Sidebar from "@/application/components/layout/Sidebar"; +vi.mock("@/application/hooks/auth/useCurrentUser", () => ({ + useCurrentUser: vi.fn(), +})); + +import { useCurrentUser } from "@/application/hooks/auth/useCurrentUser"; + describe("Sidebar", () => { + beforeEach(() => { + vi.mocked(useCurrentUser).mockReturnValue({ + profile: { + userId: "user-123", + email: "jane@example.com", + name: "Jane Doe", + username: "jane", + }, + isLoading: false, + isError: false, + error: null, + }); + }); it("renders the brand wordmark 'Composable' with 'UI' accent", () => { renderWithProviders(, { initialEntries: ["/chat"] }); @@ -62,14 +81,14 @@ describe("Sidebar", () => { it("renders the footer operator name and role", () => { renderWithProviders(, { initialEntries: ["/chat"] }); - expect(screen.getByText("Yohan")).toBeInTheDocument(); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); expect(screen.getByText("Operator")).toBeInTheDocument(); }); - it("renders the user avatar initials YH", () => { + it("renders the user avatar initials derived from the profile name", () => { renderWithProviders(, { initialEntries: ["/chat"] }); - expect(screen.getByText("YH")).toBeInTheDocument(); + expect(screen.getByText("JD")).toBeInTheDocument(); }); it("renders the brand icon letter C", () => { diff --git a/tests/unit/domain/entities/auth/currentUser.test.ts b/tests/unit/domain/entities/auth/currentUser.test.ts new file mode 100644 index 0000000..475071b --- /dev/null +++ b/tests/unit/domain/entities/auth/currentUser.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { getDisplayName, getInitials, type UserProfile } from "@/domain/entities/auth/currentUser"; + +describe("getDisplayName", () => { + it("returns the name when present", () => { + const profile: UserProfile = { userId: "u1", name: "Jane Doe", username: "jane", email: "j@x.com" }; + expect(getDisplayName(profile)).toBe("Jane Doe"); + }); + + it("falls back to username when name is blank", () => { + const profile: UserProfile = { userId: "u1", name: " ", username: "jane", email: "j@x.com" }; + expect(getDisplayName(profile)).toBe("jane"); + }); + + it("falls back to the email local part when name and username are absent", () => { + const profile: UserProfile = { userId: "u1", email: "jane@example.com" }; + expect(getDisplayName(profile)).toBe("jane"); + }); + + it("falls back to userId when no profile field is available", () => { + const profile: UserProfile = { userId: "user-123" }; + expect(getDisplayName(profile)).toBe("user-123"); + }); + + it("returns empty string for null/undefined profile", () => { + expect(getDisplayName(null)).toBe(""); + expect(getDisplayName(undefined)).toBe(""); + }); +}); + +describe("getInitials", () => { + it("returns the first letters of the first two words", () => { + expect(getInitials("Jane Doe")).toBe("JD"); + }); + + it("returns a single initial for a single word", () => { + expect(getInitials("jane")).toBe("J"); + }); + + it("uppercases the initials", () => { + expect(getInitials("john doe")).toBe("JD"); + }); + + it("returns a single space for an empty display name", () => { + expect(getInitials("")).toBe(" "); + }); + + it("ignores extra whitespace words", () => { + expect(getInitials("Jane Marie Doe")).toBe("JM"); + }); +}); \ No newline at end of file diff --git a/tests/unit/hooks/auth/useCurrentUser.test.tsx b/tests/unit/hooks/auth/useCurrentUser.test.tsx new file mode 100644 index 0000000..01ac788 --- /dev/null +++ b/tests/unit/hooks/auth/useCurrentUser.test.tsx @@ -0,0 +1,72 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import type { ReactNode } from "react"; +import { useCurrentUser } from "@/application/hooks/auth/useCurrentUser"; +import { userApi } from "@/infrastructure/api/auth/userApi"; +import type { UserProfile } from "@/domain/entities/auth/currentUser"; + +vi.mock("@/infrastructure/api/auth/userApi", () => ({ + userApi: { + me: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useCurrentUser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the profile when data loads", async () => { + // Arrange + const profile: UserProfile = { + userId: "user-123", + email: "jane@example.com", + name: "Jane Doe", + username: "jane", + }; + vi.mocked(userApi.me).mockResolvedValue(profile); + + // Act + const { result } = renderHook(() => useCurrentUser(), { wrapper: createWrapper() }); + + // Assert + await waitFor(() => expect(result.current.profile).not.toBeNull()); + expect(result.current.profile).toEqual(profile); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(false); + }); + + it("exposes the error and null profile when the call fails", async () => { + // Arrange + vi.mocked(userApi.me).mockRejectedValue(new Error("Unauthorized")); + + // Act + const { result } = renderHook(() => useCurrentUser(), { wrapper: createWrapper() }); + + // Assert + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.profile).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + }); + + it("calls userApi.me exactly once per mount", async () => { + // Arrange + vi.mocked(userApi.me).mockResolvedValue({ userId: "u1" }); + + // Act + renderHook(() => useCurrentUser(), { wrapper: createWrapper() }); + + // Assert + await waitFor(() => expect(vi.mocked(userApi.me)).toHaveBeenCalledTimes(1)); + }); +}); \ No newline at end of file