v1.4 foundation: UI guidelines, primitives, AI test fix, autofill hardening (#122)

* docs(ui): add UI guidelines as single source of truth + shadcn MCP server

- `docs/ui-guidelines.md` lays out Dracula tokens, spacing, typography,
  component picks (button/input/dialog/sheet decision tree), layout
  rules (settings split target, dashboard one-row), accessibility
  baseline (WCAG 2.1 AA + axe-core in CI), the autofill / honeypot
  pattern for measurement forms, and a reusable "Test button" UX for
  every integration. New components in v1.4 reference this doc.

- `.mcp.json` wires the official `shadcn` MCP server so the design
  system can be browsed and components installed without leaving the
  editor.

- Adds `@vitest/coverage-v8` to dev dependencies — `pnpm test
  --coverage` now reports real per-file coverage (the v1.4 cycle uses
  this to lift critical-path coverage).

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(settings/ai): connection-test honours the unsaved provider selection

Changing the AI provider in /settings, then clicking "Verbindung testen"
without saving first, used to silently run the test against the *saved*
provider instead — so the result felt unrelated to what the user had on
screen. This often surfaced as a confusing OK / failure depending on
whichever provider had previously been persisted.

The test endpoint now accepts an optional JSON body that mirrors the
form fields (provider, model, baseUrl, anthropicKey, localKey). When
present, the server resolves a transient provider from those values —
falling back to the persisted, encrypted credentials per field, so a
user with a stored Anthropic key can swap models in the dropdown
without re-typing the key. Plaintext keys never persist; the existing
SSRF guard for the Local provider's base URL is reused, the existing
rate limit (5/min) and the V3 error-leak shielding stay in place.

Empty body keeps the previous behaviour for any other caller.

Tests cover the new override path (provider forwarded, unknown
provider rejected with 422, empty body uses persisted config, config
errors surface the resolver's status). Whitespace in
`src/app/settings/page.tsx` is reformatted by Prettier as a
side-effect; logic touches only the AI section.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(ui): add Skeleton and EmptyState primitives

Two pieces every page in v1.4 needs and the codebase didn't have:

- `<Skeleton>` — a presentation `<div>` with the standard pulse
  animation, `aria-hidden`, and `motion-reduce:animate-none`. Used to
  shape the page while data loads instead of dropping a spinner. The
  visible layout no longer jumps when the first measurement arrives.
- `<EmptyState>` — the shape every list, tile, and chart panel
  reaches for when it has no data: optional icon, title, description,
  primary action; `card` (default) and `plain` variants;
  `default`/`compact` density. Lives inside an `aria-live="polite"`
  region so screen readers announce the message and its action.

Tests cover screen-reader semantics, prettier-merged class names,
density/variant switches.

These two land first because the v1.4 pages — the split settings
sections, the new dashboard hero, the redesigned admin status grid —
all assume the primitives exist. The patterns are documented in
`docs/ui-guidelines.md` §4.1–4.2.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(ui): block password-manager autofill on health-data inputs by default

Browsers and password managers were happily autofilling the user's
account credentials into measurement, medication, mood, and AI-token
fields whenever those forms didn't explicitly set `autoComplete`. The
codebase had zero `autoComplete` attributes across the data-entry
surfaces, so this happened often enough to be both a UX papercut
(account password landing in a "Notiz" field) and a real privacy issue
(secrets persisting through CSV/JSON export).

The base `<Input>` primitive now:

- Defaults `autoComplete` to `"off"` when the caller doesn't supply a
  value.
- Adds `data-lpignore="true"` and `data-1p-ignore="true"` whenever
  autoComplete resolves to `"off"`, so LastPass and 1Password skip
  the field too.
- Keeps the password manager active for any form that already passes a
  semantic `autoComplete` (`"username"`, `"email"`,
  `"current-password"`, `"new-password"`) — the existing login,
  register, and AI-key inputs continue to work unchanged.

Tests cover the three meaningful states (default off, explicit
semantic value, login-flow `username`).

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 12:05:15 +02:00
1 parent 98a57eb786
commit 359d4d19cc
14 files changed
+1589 -392

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["-y", "shadcn@latest", "mcp"]
}
}
}
+464
View File
@@ -0,0 +1,464 @@
# HealthLog UI Guidelines
> Single source of truth for visual & interaction patterns in the HealthLog
> PWA. Consult this document before adding a new component, page, or
> setting. If a pattern below doesn't fit your case, propose an addition
> here in the same PR.
Last updated: 2026-05-08 (v1.4 cycle)
---
## 1. Foundations
### 1.1 Theme
HealthLog is a Dracula-themed app with a light counterpart (Alucard). Both
themes share the same six-color signal palette (purple, pink, green, cyan,
orange, red, yellow, lavender), exposed as CSS variables and as Tailwind
`dracula-*` color tokens. Use the semantic tokens (`--primary`,
`--destructive`, `--card`, …) for chrome and the `--dracula-*` tokens for
data viz only.
| Surface | Token |
| ---------------------------- | ------------------ |
| App background | `--background` |
| Card / popover surface | `--card` |
| Primary action | `--primary` |
| Destructive action | `--destructive` |
| Secondary / muted background | `--secondary` |
| Border | `--border` |
| Focus ring | `--ring` |
| Chart series 1–5 | `--chart-1`…`-5` |
| Brand signal: purple | `--dracula-purple` |
| Brand signal: green (good) | `--dracula-green` |
| Brand signal: orange (warn) | `--dracula-orange` |
| Brand signal: red (alert) | `--dracula-red` |
| Brand signal: cyan (info) | `--dracula-cyan` |
Dark mode is the default. The theme switcher in the sidebar three-dot menu
toggles `.dark` / `.light` on `:root`. Always test both modes before
shipping a component.
### 1.2 Spacing
Tailwind's default 4-pixel scale. Reach for these stops first:
| Use | Token | Pixels |
| ------------------------------------ | ------------- | ------ |
| Inline gap between icon and label | `gap-1` | 4 |
| Tight stack inside a card | `gap-2` | 8 |
| Default form-field stack | `space-y-3` | 12 |
| Section block (heading + body) | `space-y-4` | 16 |
| Page section separator | `space-y-6` | 24 |
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
| Page-edge padding (mobile / desktop) | `px-4 / px-6` | |
Avoid arbitrary `space-y-5`, `gap-3.5`, etc. unless aligning to a fixed
external constraint.
### 1.3 Typography
The app uses the Tailwind defaults inherited via shadcn/ui. Reach for
these classes first:
| Use | Class |
| ---------------------------------- | --------------------------------------- |
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
| Section heading (h2) | `text-lg font-semibold` |
| Card title | `text-base font-medium` |
| Body text | `text-sm` |
| Muted helper / metadata | `text-sm text-muted-foreground` |
| Microcopy / footer | `text-xs text-muted-foreground` |
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
let the design system breathe.
### 1.4 Radius and elevation
`--radius: 0.625rem` (10px) is the default. The Tailwind tokens
`rounded-md` (`--radius-md` ≈ 8px) and `rounded-lg` (`--radius` ≈ 10px)
cover 95% of cases. Use `rounded-2xl` only for hero cards or large
modals.
Elevation is achieved via `shadow-xs`, `shadow-sm`, or
`shadow-[0_1px_0_rgba(255,255,255,0.04)]` for very subtle separators on
dark backgrounds. Avoid Tailwind's heavy shadows (`shadow-lg`+) in chrome.
---
## 2. Components
This section is **prescriptive, not exhaustive**. Each entry pairs a
Radix/shadcn primitive with the cases it's the right answer for and the
cases where another primitive should win.
### 2.1 Buttons
Every button comes from `@/components/ui/button`. Pick by purpose:
| Purpose | Variant | Size |
| ----------------------------------------------------- | ------------- | ----------------- |
| Primary action on a page or dialog | `default` | `default` |
| Cancel, dismiss, secondary action next to primary | `outline` | `default` |
| Tertiary in-row action (edit, copy, snooze) | `ghost` | `sm` |
| Destructive (delete, revoke, disconnect) | `destructive` | `default` or `sm` |
| Icon-only action (toolbar, chart toggles, table rows) | `ghost` | `icon-sm` |
| Inline link to external help docs | `link` | `sm` |
| Toolbar / table row action when space is tight | `outline` | `xs` |
**Position rules.**
- Forms (full-width or modal): primary on the **right**, cancel/back on
the **left**. Match shadcn dialog defaults.
- Settings sections: a single sticky `Save` button at the **right end**
of the section header _or_ per-row inline `Save`. Never both in the
same section.
- Test buttons (Telegram test, AI ping, etc.): immediately to the right
of the related Save / Apply control. Never below — keeps the eye
motion horizontal.
- Destructive actions live in their own confirm dialog with a
type-to-confirm input when the action is irreversible (delete account,
revoke API token, drop user data).
**Disabled state.** A disabled button must always come with a tooltip or
helper text explaining why. Silent disabled buttons frustrate users.
### 2.2 Inputs and forms
We use `react-hook-form` + Zod schemas from `src/lib/validations/`.
Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
- **Text / number / email**: `<Input>`. Always set `autocomplete`. See §4.4.
- **Multi-line**: shadcn `<Textarea>` (add via `pnpm dlx shadcn@latest add textarea` if missing).
- **Single choice ≤4 options**: prefer `<RadioGroup>` or a SegmentedControl
pattern using `<Tabs>`. Never use `<Select>` for tiny enums — extra clicks.
- **Single choice 5–15 options**: `<Select>` (shadcn).
- **Single choice >15 options**: `<Combobox>` (shadcn `<Command>` + `<Popover>`)
with type-ahead.
- **Multi choice**: `<Checkbox>` group, or shadcn `<MultiSelect>` when 7+ choices.
- **Date / datetime**: shadcn `<Calendar>` inside a `<Popover>`. Always
label the timezone (Europe/Berlin).
- **Numeric range**: `<Slider>` only when the value space is continuous
_and_ the user routinely sweeps. For most thresholds and counts, an
`<Input type="number">` plus stepper buttons is faster and less
fiddly. See §6 (Slider audit) for the migration path.
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
measurements to export).
**Validation.** Inline messages below the field. Field gets
`aria-invalid="true"` and a destructive border. Form-level errors land
in a `<Alert variant="destructive">` at the top. Never show a toast for
a field-level error.
### 2.3 Dialog vs Sheet vs Drawer
The wrong choice here is the most visible UX mistake we make. Pick by
intent, not by hand-feel.
| Use | Primitive | Rationale |
| --------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------- |
| Confirm a destructive or irreversible action | `Dialog` (`AlertDialog` shape) | Modal blocks accidental dismiss; user must read. |
| Inspect a single item's details with no edit | `Dialog` | Centered, focused, dismissible. |
| Edit a single item with ≤6 fields | `Dialog` | Tight focus, quick save. |
| Edit a single item with 7+ fields, or with deep sub-sections | `Sheet` (right) | Wide working surface, scrollable, doesn't crop on tall content. |
| Mobile bottom-up sub-action (filter, sort, multi-select picker) | `Sheet` (bottom) | Thumb-friendly, dismiss by swipe. |
| Multi-step wizard | Full route | Page transitions communicate progress better than a stuck modal. |
| Side-by-side compare or trend deep-dive | Full route | Charts need the viewport. |
Never nest Dialogs. If a Dialog needs another modal action, escalate the
inner step to a separate route or use shadcn's `<AlertDialog>` chained on
top of a `<Sheet>`.
### 2.4 Cards and lists
- **Card**: a self-contained unit of content. Use `<Card>` from
shadcn. Header, body, footer follow Pretendard-style hierarchy.
- **Tile**: a _small_ card showing a single metric on the dashboard.
See §3.2 for the one-row constraint. Tiles must include current value,
unit, optional sparkline, and a single tap-target area.
- **List row**: `<div className="flex items-center justify-between gap-3 px-3 py-2 hover:bg-accent/40 rounded-md">` — left side has icon + primary
- meta, right side has trailing controls. Never put primary actions in
the right-side ellipsis menu — only secondary or destructive.
- **Empty state**: every list and tile has an explicit empty-state
component with an icon, a one-sentence explanation, and a single
primary action ("Add measurement", "Connect Withings"). See §5.
### 2.5 Tables
`<Table>` (shadcn) for tabular data only — measurements list, audit log,
admin views. Default to `<Card>` + list rows for everything else;
tables on mobile collapse poorly. When you must use a table on mobile,
swap to a vertical list at `sm:` breakpoint.
### 2.6 Status indicators
Use the same color taxonomy across the app:
| State | Color token | Examples |
| --------- | -------------------- | ---------------------------------------------- |
| Good | `--dracula-green` | Connected, in target, healthy |
| Info | `--dracula-cyan` | Up-to-date, neutral signal |
| Caution | `--dracula-orange` | Mildly out of range, expiring token, slow sync |
| Alert | `--dracula-red` | Out of range, sync failed, critical |
| Pending | `--muted-foreground` | Loading, indeterminate |
| Highlight | `--dracula-purple` | New / featured / promoted |
Show a colored dot **plus** a label. Never rely on color alone.
---
## 3. Layouts
### 3.1 Page shell
Every authenticated page is wrapped by `<TopBar>` + `<SidebarNav>` (left)
- `<BottomNav>` (mobile). Don't reinvent these on a per-page basis.
* Mobile breakpoint: `<sm` shows bottom-nav, hides sidebar.
* `sm`–`md`: sidebar in icons-only mode.
* `md+`: sidebar full mode unless user has collapsed it
(`localStorage:healthlog-sidebar-collapsed`).
Page content goes in `<main className="mx-auto max-w-5xl px-4 sm:px-6 py-6 space-y-6">` unless the page is a full-bleed dashboard or settings shell.
### 3.2 Dashboard tiles — one-row rule
Tiles at the top of the dashboard live in a **single row**. Width caps
at the chart's width directly below. **Never** wrap to a second row.
If the user enables more tiles than fit, the dashboard surfaces a banner
inviting them to deactivate or reorder via Settings → Dashboard. The
layout editor in Settings shows a live preview of the constraint and
flags overflow before save.
CSS: `grid-cols-N` where N = active tile count, capped by
`Math.min(activeTiles, maxTilesForViewport(width))`. Drag-reorder via
`@dnd-kit/sortable`. Toggle-show via the layout editor only — not in the
tile itself, to avoid accidental hide.
### 3.3 Settings — `/settings/[section]`
The 1.4 release splits the legacy 3150-LOC settings monolith into one
route per top-level section:
- `/settings/account` — profile, password, passkeys, account deletion.
- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push.
- `/settings/notifications` — channel matrix + quiet hours + reminders.
- `/settings/dashboard` — tile layout editor, default ranges, threshold
overrides.
- `/settings/ai` — AI provider selection + connection + test.
- `/settings/api` — API tokens for headless ingest + Bearer issuance.
- `/settings/advanced` — exports, imports, danger zone.
- `/settings/about` — version, build, license, links to docs/changelog.
Each section's component lives at `src/components/settings/<section>.tsx`
and stays under 400 LOC. Cross-section search lives in the section
sidebar header.
The legacy `/settings` route 301-redirects to `/settings/account`.
### 3.4 Admin — `/admin/[section]`
Same shape as Settings, with the right-hand sidebar listing:
- Users
- Integrations status
- Monitoring (Wide Events, GlitchTip, Umami)
- Backups
- Maintenance
- Audit log
Status badges use the §2.6 color taxonomy. Each section opens to a card
grid, never a long fluid list.
---
## 4. Patterns
### 4.1 Loading states
- **Page-level**: a `<Skeleton>` matching the final layout. Never show a
full-screen spinner. Mount the skeleton inside the same `<main>` shell
the loaded page renders into.
- **Inline**: button loading state shows a spinner + label change
("Saving…", "Testing…"). Disable other form controls while pending.
- **Long-running**: progress bar with a remaining-step count; for
background tasks (export, backup), surface in a toast that links to
the operation log instead of blocking the UI.
Skeletons must respect `prefers-reduced-motion: reduce`.
### 4.2 Empty states
A reusable `<EmptyState>` shape:
```tsx
<EmptyState
icon={<Plus className="size-6" />}
title={t("empty.measurements.title")}
description={t("empty.measurements.description")}
action={<Button>{t("empty.measurements.add")}</Button>}
/>
```
Every list, every chart, every dashboard tile that can be empty has an
explicit, translated empty-state. No "No data" placeholder strings.
### 4.3 Error states
- **Form errors**: inline below the offending field; aggregate at the
top of the form for global errors (e.g. "Server unreachable").
- **Page errors**: Next.js `error.tsx` boundaries with a friendly
recovery action ("Retry", "Go home"). Send the error to GlitchTip
through the existing client.
- **Network errors during save**: `<Alert variant="destructive">` at
the top of the form _plus_ a retry button. Never silently fail.
### 4.4 Forms and password managers
- Measurement, medication, and mood form fields use
`autocomplete="off"` on every input that browsers keep guessing wrong.
- API-token inputs and AI-provider key inputs use
`autocomplete="new-password"` so the browser doesn't autofill the
user's account password.
- Honey-pot fields (offscreen `<input>` with `autocomplete="email"` and
`tabIndex={-1}`) sit at the top of measurement forms to catch
aggressive autofill.
- Forms always include a hidden `<input type="text" name="username"
autocomplete="username" value={user.email} readOnly tabIndex={-1}>`
inside password change and passkey screens so password managers
attribute saved credentials correctly.
### 4.5 Test buttons (every integration)
Each configurable integration ships a Test button next to its Save
control. Behavior:
- **Trigger**: ad-hoc API call to the integration with a small,
identifiable payload ("HealthLog test ping").
- **State**: button shows a spinner with localized "Testing…" label.
Result lands inline in a `<StatusCallout>` directly below — green for
success with the relevant detail (last sync, message ID, response
status) or red for the error (sanitized message — never leak the
upstream URL or API key).
- **History**: each test writes a row to a tiny per-integration log
visible in the section. Last 5 tests, timestamp + result.
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
Withings, moodLog, GlitchTip, Umami, email (when wired), backup
target. Headless E2E asserts presence + working call for each.
### 4.6 Server actions and mutations
We use `react-hook-form` + TanStack Query mutations, never raw fetch
calls in components. Every mutation:
1. Optimistically updates the cache with a rollback handler.
2. Toast on success ("Saved" — translated) and on error (with retry).
3. Re-validates queries it touches.
For multi-step flows, write a typed `apiClient` helper in
`src/lib/api/clients/` rather than re-doing fetch in three places.
### 4.7 Inline help
A small `?` `<Tooltip>` next to the label is the default for non-trivial
fields (e.g. "Why does this default to ESH 2023?"). Tooltips must be
keyboard-accessible (Radix handles this) and translated.
For long explanations, link out to `https://docs.healthlog.dev/...` —
never embed multi-paragraph help inline.
---
## 5. Accessibility
We aim for **WCAG 2.1 AA**, with the following non-negotiables:
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. Verified by `@axe-core/playwright` in CI on every page.
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
(WCAG 2.5.5).
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
every customized component.
- Every interactive element has an accessible name. Icon-only buttons
get `<span className="sr-only">` labels and `aria-label`.
- `prefers-reduced-motion: reduce` disables animations beyond a 200 ms
fade.
- Charts include text-equivalent summaries (`aria-describedby` linked to
a `<p className="sr-only">` summary) and exposed data via the visible
legend / table view toggle.
CI fails on any `serious` or `critical` axe-core violation.
---
## 6. Slider audit and migration
Sliders look pretty but are a poor fit for most numeric inputs because
they sacrifice precision. The 1.4 audit catalogues every `<Slider>` in
the codebase and migrates the inappropriate ones:
| Setting | Current control | Better control |
| ----------------------------------- | --------------- | ------------------------------- |
| Mood intensity (0–10) | Slider | SegmentedControl |
| Notification quiet-hours range | Two sliders | Two `<Input type="time">` |
| Threshold-override numeric values | Slider | `<Input type="number">` + steps |
| AI insight depth (1–5 levels) | Slider | `<RadioGroup>` cards |
| Export retention days (7/30/90/365) | Slider | `<Select>` with preset options |
A slider stays appropriate for "scrub through history" or "set chart
zoom range" — operations where the value is continuous and live preview
is the point.
---
## 7. Internationalization
All user-facing strings come from `messages/en.json` (default) and
`messages/de.json`. Use `useTranslations()` on the client and
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`) on the
server. Numbers, dates, currencies, and units always go through
`useFormatters()` — never hand-roll `Intl.NumberFormat(...)` with a
fixed locale.
Server-rendered content (PDFs, notifications, server-issued category
labels) must use the locale of the user the content is for, not the
locale of the request that triggered the job.
---
## 8. Motion
Default transitions: `transition-all duration-150 ease-out`. Heavier
animations (insight cards, dialog enter/exit) use the shadcn defaults.
Reduce-motion always wins.
Loading skeletons pulse via Tailwind `animate-pulse`. Don't introduce
custom keyframes without adding a `prefers-reduced-motion` fallback.
---
## 9. Component checklist (for new features)
Before opening a PR for a new component or page, walk through:
- [ ] Uses tokens from §1, not hard-coded hex/rgba.
- [ ] Buttons follow §2.1 variant + position rules.
- [ ] Inputs follow §2.2; correct `autocomplete`; honey-pots where needed.
- [ ] Dialog vs Sheet vs Drawer per §2.3.
- [ ] Loading, empty, and error states from §4.1–4.3 implemented.
- [ ] All strings via `t()`; numbers via formatters.
- [ ] Mobile viewport tested (Chrome DevTools 375×667 and 414×896).
- [ ] Light + Dark mode tested.
- [ ] Keyboard navigation: Tab order, Enter to submit, Esc to dismiss.
- [ ] axe-core clean (no serious/critical).
- [ ] Headless screenshot test added if UI is non-trivial.
---
_Maintained as part of the v1.4 release cycle. Propose changes alongside
the code that motivates them._
+1
View File
@@ -55,6 +55,7 @@
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"pdf-parse": "^2.4.5",
+107 -11
View File
@@ -19,7 +19,7 @@ importers:
version: 7.8.0
'@prisma/client':
specifier: ^7.8.0
version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3)
version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3)
'@simplewebauthn/browser':
specifier: ^13.3.0
version: 13.3.0
@@ -58,7 +58,7 @@ importers:
version: 12.18.2
prisma:
specifier: ^7.8.0
version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -108,6 +108,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^5.1.4
version: 5.1.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
'@vitest/coverage-v8':
specifier: ^4.1.5
version: 4.1.5(vitest@4.1.5)
eslint:
specifier: ^9
version: 9.39.3(jiti@2.6.1)
@@ -137,7 +140,7 @@ importers:
version: 6.0.3
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
packages:
@@ -290,6 +293,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@dotenvx/dotenvx@1.64.0':
resolution: {integrity: sha512-6+xRpZaWuHXEqnhBjae+VmQI9Uaqw5Uzu/ScpO+W7ww9Zp3lHSNBoNjFcUxhrCyc7pRGQzyDjhKzloqrPHERiQ==}
hasBin: true
@@ -2390,6 +2397,15 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/coverage-v8@4.1.5':
resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==}
peerDependencies:
'@vitest/browser': 4.1.5
vitest: 4.1.5
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
@@ -2524,6 +2540,9 @@ packages:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
ast-v8-to-istanbul@1.0.0:
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -3461,6 +3480,9 @@ packages:
resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==}
engines: {node: '>=16.9.0'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
@@ -3715,6 +3737,18 @@ packages:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
@@ -3726,6 +3760,9 @@ packages:
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3922,6 +3959,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -5479,6 +5523,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@dotenvx/dotenvx@1.64.0':
dependencies:
commander: 11.1.0
@@ -6153,16 +6199,16 @@ snapshots:
'@prisma/client-runtime-utils@7.8.0': {}
'@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3)':
'@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@prisma/client-runtime-utils': 7.8.0
optionalDependencies:
prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
typescript: 6.0.3
'@prisma/config@7.8.0':
'@prisma/config@7.8.0(magicast@0.5.2)':
dependencies:
c12: 3.3.4
c12: 3.3.4(magicast@0.5.2)
deepmerge-ts: 7.1.5
effect: 3.20.0
empathic: 2.0.0
@@ -7451,6 +7497,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.5(vitest@4.1.5)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.5
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -7628,6 +7688,12 @@ snapshots:
dependencies:
tslib: 2.8.1
ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
async-function@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -7703,7 +7769,7 @@ snapshots:
bytes@3.1.2: {}
c12@3.3.4:
c12@3.3.4(magicast@0.5.2):
dependencies:
chokidar: 5.0.0
confbox: 0.2.4
@@ -7717,6 +7783,8 @@ snapshots:
perfect-debounce: 2.1.0
pkg-types: 2.3.0
rc9: 3.0.1
optionalDependencies:
magicast: 0.5.2
call-bind-apply-helpers@1.0.2:
dependencies:
@@ -8713,6 +8781,8 @@ snapshots:
hono@4.12.16: {}
html-escaper@2.0.2: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
@@ -8933,6 +9003,19 @@ snapshots:
isexe@3.1.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -8946,6 +9029,8 @@ snapshots:
jose@6.2.3: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -9114,6 +9199,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
math-intrinsics@1.1.0: {}
media-typer@1.1.0: {}
@@ -9534,9 +9629,9 @@ snapshots:
dependencies:
parse-ms: 4.0.0
prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3):
prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3):
dependencies:
'@prisma/config': 7.8.0
'@prisma/config': 7.8.0(magicast@0.5.2)
'@prisma/dev': 0.24.3(typescript@6.0.3)
'@prisma/engines': 7.8.0
'@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -10454,7 +10549,7 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.32.0
vitest@4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)):
vitest@4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
@@ -10478,6 +10573,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
transitivePeerDependencies:
- msw
+116 -11
View File
@@ -3,12 +3,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mocks must be hoisted before importing the route.
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })),
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/provider", async () => {
const actual =
await vi.importActual<typeof import("@/lib/ai/provider")>(
"@/lib/ai/provider",
);
return {
...actual,
resolveProviderForTest: vi.fn(),
};
});
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
@@ -19,10 +29,11 @@ vi.mock("@/lib/logging/context", () => ({
}));
import { POST } from "../route";
import { resolveProvider } from "@/lib/ai/provider";
import { resolveProviderForTest } from "@/lib/ai/provider";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(resolveProviderForTest).mockReset();
});
interface ApiErrorEnvelope {
@@ -30,6 +41,30 @@ interface ApiErrorEnvelope {
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
}
function emptyRequest(): Request {
return new Request("http://localhost/api/ai/test", {
method: "POST",
headers: { "content-length": "0" },
});
}
function jsonRequest(body: unknown): Request {
const text = JSON.stringify(body);
return new Request("http://localhost/api/ai/test", {
method: "POST",
headers: {
"content-type": "application/json",
"content-length": String(text.length),
},
body: text,
});
}
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
// directly to the client, leaking provider URLs / partial keys / internal
// headers. Server now logs full details via annotate() and responds with
@@ -38,7 +73,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
function makeProviderThatThrows(
err: Error & { httpStatus?: number; bodyExcerpt?: string },
) {
vi.mocked(resolveProvider).mockResolvedValue({
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "openai",
generateCompletion: vi.fn(async () => {
throw err;
@@ -49,11 +84,14 @@ describe("POST /api/ai/test — provider error leak guard", () => {
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
const err = Object.assign(
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
{ httpStatus: 401 as const, bodyExcerpt: '{"error":"invalid api key sk-secret"}' },
{
httpStatus: 401 as const,
bodyExcerpt: '{"error":"invalid api key sk-secret"}',
},
);
makeProviderThatThrows(err);
const response = await POST();
const response = await POST(emptyRequest() as never);
const body = (await response.json()) as ApiErrorEnvelope;
expect(response.status).toBe(502);
@@ -67,7 +105,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
makeProviderThatThrows(
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
);
const response = await POST();
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider rate-limited the request",
@@ -78,7 +116,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
makeProviderThatThrows(
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
);
const response = await POST();
const response = await POST(emptyRequest() as never);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider returned a server error",
);
@@ -86,9 +124,76 @@ describe("POST /api/ai/test — provider error leak guard", () => {
it("returns the unknown-error fallback otherwise", async () => {
makeProviderThatThrows(new Error("ECONNRESET"));
const response = await POST();
const response = await POST(emptyRequest() as never);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider connection failed",
);
});
});
// v1.4 fix: the dropdown in /settings was not honoured — the test always
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
// The route now accepts a JSON override body and forwards the user's
// unsaved selection to resolveProviderForTest().
describe("POST /api/ai/test — dropdown-aware override", () => {
function makeProviderThatSucceeds(model: string) {
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"ok":true}',
providerType: "anthropic",
model,
tokensUsed: 5,
})),
} as never);
}
it("forwards a dropdown override to resolveProviderForTest", async () => {
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
const response = await POST(
jsonRequest({
provider: "ANTHROPIC",
model: "claude-3-5-sonnet-latest",
}) as never,
);
expect(response.status).toBe(200);
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
provider: "ANTHROPIC",
model: "claude-3-5-sonnet-latest",
});
const body = (await response.json()) as ApiSuccessEnvelope<{
providerType: string;
model: string;
}>;
expect(body.data.providerType).toBe("anthropic");
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
});
it("rejects unknown provider values with 422", async () => {
const response = await POST(
jsonRequest({ provider: "MALICIOUS" }) as never,
);
expect(response.status).toBe(422);
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("uses the persisted config when the body is empty", async () => {
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {});
});
it("surfaces config errors as the same status as the resolver throws", async () => {
const { AITestConfigError } = await import("@/lib/ai/provider");
vi.mocked(resolveProviderForTest).mockRejectedValueOnce(
new AITestConfigError(422, "Anthropic API key not configured"),
);
const response = await POST(
jsonRequest({ provider: "ANTHROPIC" }) as never,
);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toBe("Anthropic API key not configured");
});
});
+47 -4
View File
@@ -1,19 +1,62 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { resolveProvider } from "@/lib/ai/provider";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
resolveProviderForTest,
AITestConfigError,
type AITestOverride,
} from "@/lib/ai/provider";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const overrideSchema = z
.object({
provider: z
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
.optional()
.nullable(),
model: z.string().min(1).max(120).optional().nullable(),
baseUrl: z.string().url().max(2048).optional().nullable(),
anthropicKey: z.string().min(1).max(500).optional().nullable(),
localKey: z.string().min(1).max(500).optional().nullable(),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "ai.test" } });
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
if (!rl.allowed) return apiError("Too many test requests", 429);
const provider = await resolveProvider(user.id);
// Body is optional. Empty body → behaves like before (test the saved
// config). Non-empty body → tests the unsaved selection without
// mutating the user row. Plaintext keys never persist.
let override: AITestOverride = {};
const contentLength = Number(request.headers.get("content-length") ?? "0");
if (contentLength > 0) {
const { data, error } = await safeJson<unknown>(request);
if (error) return error;
const parsed = overrideSchema.safeParse(data);
if (!parsed.success) {
return apiError("Invalid override payload", 422);
}
override = parsed.data;
}
let provider;
try {
provider = await resolveProviderForTest(user.id, override);
} catch (e) {
if (e instanceof AITestConfigError) {
return apiError(e.message, e.status);
}
throw e;
}
if (provider.type === "none") {
return apiError("No AI provider configured", 422);
}
+426 -358
View File
@@ -297,383 +297,387 @@ export default function SettingsPage() {
</p>
</div>
{user.role === "ADMIN" && (
<div className="bg-card border-border mb-6 rounded-xl border p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.adminAreaTitle")}
</h2>
</div>
<p className="text-muted-foreground text-sm">
{t("settings.adminAreaDescription")}
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin">
<Shield className="mr-2 h-4 w-4" />
{t("settings.openAdminConsole")}
</Link>
</Button>
</div>
</div>
)}
<div className="space-y-8">
<section id="section-allgemein" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryGeneral")}
</h2>
{/* Profile Section */}
<div
id="profil"
className="bg-card border-border rounded-xl border p-6"
>
<div className="mb-4 flex items-center gap-2">
<User className="text-primary h-5 w-5" />
{user.role === "ADMIN" && (
<div className="bg-card border-border mb-6 rounded-xl border p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.profile")}
{t("settings.adminAreaTitle")}
</h2>
</div>
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
<Input id="username" value={user.username} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
/>
</div>
</div>
<p className="text-muted-foreground text-sm">
{t("settings.adminAreaDescription")}
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin">
<Shield className="mr-2 h-4 w-4" />
{t("settings.openAdminConsole")}
</Link>
</Button>
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
<select
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">
{t("settings.genderFemale")}
<div className="space-y-8">
<section id="section-allgemein" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryGeneral")}
</h2>
{/* Profile Section */}
<div
id="profil"
className="bg-card border-border rounded-xl border p-6"
>
<div className="mb-4 flex items-center gap-2">
<User className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
</div>
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
<Input id="username" value={user.username} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
<select
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="height">{t("settings.height")}</Label>
<Input
id="height"
type="number"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
<Input
id="dob"
type="date"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
max={new Date().toISOString().slice(0, 10)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="language-select">
{t("settings.language")}
</Label>
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
</select>
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="height">{t("settings.height")}</Label>
<Input
id="height"
type="number"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
/>
</div>
))}
</select>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
<Input
id="dob"
type="date"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
max={new Date().toISOString().slice(0, 10)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="language-select">
{t("settings.language")}
{saveMsg && (
<p
role="alert"
className={`text-sm ${
saveMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{saveMsg}
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</section>
<section id="section-sicherheit" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.security")}
</h2>
<div
id="passkeys"
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="mb-4 flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.passkeys")}
</h2>
</div>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<div className="mt-4 flex justify-end">
<Button
variant="outline"
onClick={handleAddPasskey}
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
{passkeyMsg && (
<p
role="alert"
className={`mt-2 text-sm ${
passkeyMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{passkeyMsg}
</p>
)}
</div>
</div>
<div
id="passwort"
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.passwordReset")}
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
>
{t("settings.changePassword")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
<Dialog
open={passwordDialogOpen}
onOpenChange={(open) => {
setPasswordDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordMsg(null);
setPasswordMsgType(null);
}
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("settings.passwordReset")}</DialogTitle>
<DialogDescription>
{t("settings.changePasswordDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
<PasswordInput
id="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">
{t("settings.newPassword")}
</Label>
<PasswordInput
id="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{saveMsg && (
{passwordMsg && (
<p
role="alert"
className={`text-sm ${
saveMsgType === "success"
passwordMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{saveMsg}
{passwordMsg}
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</section>
<section id="section-sicherheit" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">{t("settings.security")}</h2>
<div
id="passkeys"
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="mb-4 flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.passkeys")}
</h2>
</div>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<div className="mt-4 flex justify-end">
<Button
type="submit"
variant="outline"
onClick={handleAddPasskey}
disabled={passkeyLoading}
disabled={passwordSaving}
>
{passkeyLoading ? (
{passwordSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
<Save className="mr-2 h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
{passkeyMsg && (
<p
role="alert"
className={`mt-2 text-sm ${
passkeyMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{passkeyMsg}
</p>
)}
</div>
</div>
<div id="passwort" className="bg-card border-border scroll-mt-28 rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.passwordReset")}
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
>
{t("settings.changePassword")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
<Dialog
open={passwordDialogOpen}
onOpenChange={(open) => {
setPasswordDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordMsg(null);
setPasswordMsgType(null);
}
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("settings.passwordReset")}</DialogTitle>
<DialogDescription>
{t("settings.changePasswordDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">
{t("settings.newPassword")}
</Label>
<PasswordInput
id="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{passwordMsg && (
<p
role="alert"
className={`text-sm ${
passwordMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{passwordMsg}
</p>
)}
<Button
type="submit"
variant="outline"
disabled={passwordSaving}
>
{passwordSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</DialogContent>
</Dialog>
</section>
{hasNotificationServices && (
<section
id="section-benachrichtigungen"
className="scroll-mt-28 space-y-3"
>
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryNotifications")}
</h2>
{serviceAvailability.telegramGlobal && (
<TelegramSection
id="telegram"
isAuthenticated={isAuthenticated}
/>
)}
{serviceAvailability.ntfyGlobal && (
<NtfySection id="ntfy" isAuthenticated={isAuthenticated} />
)}
{serviceAvailability.webPushGlobal && (
<WebPushSection id="web-push" />
)}
</section>
)}
</form>
</DialogContent>
</Dialog>
</section>
{hasNotificationServices && (
<section
id="section-personalization"
id="section-benachrichtigungen"
className="scroll-mt-28 space-y-3"
>
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryPersonalization")}
{t("settings.categoryNotifications")}
</h2>
<DashboardLayoutSection id="dashboard-layout" />
<ThresholdsSection id="thresholds" />
{serviceAvailability.telegramGlobal && (
<TelegramSection
id="telegram"
isAuthenticated={isAuthenticated}
/>
)}
{serviceAvailability.ntfyGlobal && (
<NtfySection id="ntfy" isAuthenticated={isAuthenticated} />
)}
{serviceAvailability.webPushGlobal && (
<WebPushSection id="web-push" />
)}
</section>
)}
<section id="section-integration" className="scroll-mt-28 space-y-3">
<section
id="section-personalization"
className="scroll-mt-28 space-y-3"
>
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryPersonalization")}
</h2>
<DashboardLayoutSection id="dashboard-layout" />
<ThresholdsSection id="thresholds" />
</section>
<section id="section-integration" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryIntegration")}
</h2>
<WithingsSection id="withings" isAuthenticated={isAuthenticated} />
{serviceAvailability.moodLogGlobal !== false && (
<MoodLogSection t={t} />
)}
<InsightsSettingsSection
id="insights"
isAuthenticated={isAuthenticated}
/>
</section>
{serviceAvailability.apiGlobal && (
<section id="section-api" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryIntegration")}
{t("settings.categoryApi")}
</h2>
<WithingsSection id="withings" isAuthenticated={isAuthenticated} />
{serviceAvailability.moodLogGlobal !== false && <MoodLogSection t={t} />}
<InsightsSettingsSection
id="insights"
<ApiEndpointsSection id="api-endpoints" />
<ApiTokensSection
id="api-tokens"
isAuthenticated={isAuthenticated}
/>
</section>
)}
{serviceAvailability.apiGlobal && (
<section id="section-api" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.categoryApi")}
</h2>
<ApiEndpointsSection id="api-endpoints" />
<ApiTokensSection
id="api-tokens"
isAuthenticated={isAuthenticated}
/>
</section>
)}
<section id="section-export" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.export")}
</h2>
<ExportSection id="export" />
</section>
<section id="section-export" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">{t("settings.export")}</h2>
<ExportSection id="export" />
</section>
<section id="section-danger-zone" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.dangerZoneTitle")}
</h2>
<DataResetSection id="daten" />
</section>
</div>
<section id="section-danger-zone" className="scroll-mt-28 space-y-3">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("settings.dangerZoneTitle")}
</h2>
<DataResetSection id="daten" />
</section>
</div>
</div>
);
}
@@ -827,7 +831,10 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
</table>
</div>
{deleteMsg && (
<div role="alert" className="text-destructive mt-2 flex items-center gap-2 text-sm">
<div
role="alert"
className="text-destructive mt-2 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4" />
{deleteMsg}
</div>
@@ -1326,7 +1333,8 @@ function ApiTokensSection({
)}
{latestActiveUse && (
<Badge variant="outline" className="text-xs">
{t("settings.tokenTableLastUsed")}: {formatDateTime(latestActiveUse)}
{t("settings.tokenTableLastUsed")}:{" "}
{formatDateTime(latestActiveUse)}
</Badge>
)}
</div>
@@ -1368,7 +1376,11 @@ function ApiTokensSection({
</div>
)}
{tokenMsg && <p role="alert" className="text-destructive text-sm">{tokenMsg}</p>}
{tokenMsg && (
<p role="alert" className="text-destructive text-sm">
{tokenMsg}
</p>
)}
{/* Token table (active) */}
<div>
@@ -1586,9 +1598,8 @@ function ExportSection({ id }: { id: string }) {
if (!res.ok) return;
const json = await res.json();
const { generateDoctorReportPDF } = await import(
"@/lib/doctor-report-pdf"
);
const { generateDoctorReportPDF } =
await import("@/lib/doctor-report-pdf");
const doc = generateDoctorReportPDF(json.data, { t, locale });
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
@@ -2445,7 +2456,12 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
});
if (res.ok) {
const json = await res.json();
setMsg(t("settings.moodLogSyncResult").replace("{count}", String(json.data.imported)));
setMsg(
t("settings.moodLogSyncResult").replace(
"{count}",
String(json.data.imported),
),
);
setMsgType("success");
await refetchStatus();
} else {
@@ -2469,19 +2485,29 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
}
return (
<div id="moodlog" className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-4">
<div
id="moodlog"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Smile className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
<a href="https://moodlog.onback.io" target="_blank" rel="noopener noreferrer" className="hover:underline">
<a
href="https://moodlog.onback.io"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{t("settings.moodLogTitle")}
</a>
</h2>
</div>
{status?.configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{status.enabled ? t("settings.withingsConnected") : t("settings.configured")}
{status.enabled
? t("settings.withingsConnected")
: t("settings.configured")}
</Badge>
)}
</div>
@@ -2512,7 +2538,11 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<Button type="submit" disabled={saving || (!url.trim() && !apiKey.trim())} size="sm">
<Button
type="submit"
disabled={saving || (!url.trim() && !apiKey.trim())}
size="sm"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
@@ -2527,7 +2557,11 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
<div>
<Label>{t("settings.moodLogWebhookSecret")}</Label>
<div className="flex gap-2">
<Input value={status.webhookSecret} readOnly className="font-mono text-xs" />
<Input
value={status.webhookSecret}
readOnly
className="font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
@@ -2540,7 +2574,7 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
{t("common.copied").replace("!", "")}
</Button>
</div>
<p className="text-muted-foreground text-xs mt-1">
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.moodLogWebhookSecretHelp")}
</p>
</div>
@@ -2581,7 +2615,9 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("settings.moodLogFullSyncTitle")}</AlertDialogTitle>
<AlertDialogTitle>
{t("settings.moodLogFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.moodLogFullSyncDescription")}
</AlertDialogDescription>
@@ -2604,7 +2640,9 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("settings.moodLogDisconnectTitle")}</AlertDialogTitle>
<AlertDialogTitle>
{t("settings.moodLogDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.moodLogDisconnectDescription")}
</AlertDialogDescription>
@@ -2623,7 +2661,10 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
{/* Status message */}
{msg && (
<p role="alert" className={`text-sm ${msgType === "error" ? "text-destructive" : "text-dracula-green"}`}>
<p
role="alert"
className={`text-sm ${msgType === "error" ? "text-destructive" : "text-dracula-green"}`}
>
{msg}
</p>
)}
@@ -2694,7 +2735,8 @@ function InsightsSettingsSection({
}
}, [queryClient, t]);
const hasProvider = settings?.codexStatus === "connected" || settings?.hasAdminKey;
const hasProvider =
settings?.codexStatus === "connected" || settings?.hasAdminKey;
async function handleConnect() {
window.location.href = "/api/auth/codex/authorize";
@@ -2704,7 +2746,9 @@ function InsightsSettingsSection({
setDisconnecting(true);
setMsg(null);
try {
const res = await fetch("/api/auth/codex/disconnect", { method: "DELETE" });
const res = await fetch("/api/auth/codex/disconnect", {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error);
@@ -2802,9 +2846,14 @@ function InsightsSettingsSection({
<div>
<p className="text-sm font-medium">ChatGPT verbunden</p>
<p className="text-muted-foreground text-xs">
Insights werden über dein ChatGPT-Abo generiert — keine zusätzlichen Kosten.
Insights werden über dein ChatGPT-Abo generiert — keine
zusätzlichen Kosten.
{settings.codexConnectedAt && (
<> Verbunden seit {formatDateTime(settings.codexConnectedAt)}.</>
<>
{" "}
Verbunden seit {formatDateTime(settings.codexConnectedAt)}
.
</>
)}
</p>
</div>
@@ -2828,17 +2877,23 @@ function InsightsSettingsSection({
<div>
<p className="text-sm font-medium">Mit ChatGPT verbinden</p>
<p className="text-muted-foreground text-xs">
Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte Gesundheitsanalysen basierend auf
aktuellen medizinischen Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.
Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte
Gesundheitsanalysen basierend auf aktuellen medizinischen
Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.
</p>
</div>
<Button variant="outline" onClick={handleConnect} className="w-full sm:w-auto">
<Button
variant="outline"
onClick={handleConnect}
className="w-full sm:w-auto"
>
<Sparkles className="mr-2 h-4 w-4" />
Mit ChatGPT verbinden
</Button>
{settings?.hasAdminKey && (
<p className="text-muted-foreground text-xs">
Alternativ nutzt HealthLog den vom Administrator konfigurierten KI-Anbieter.
Alternativ nutzt HealthLog den vom Administrator
konfigurierten KI-Anbieter.
</p>
)}
</div>
@@ -2986,7 +3041,20 @@ function UserAIProviderSubsection() {
setTesting(true);
setTestMsg(null);
try {
const res = await fetch("/api/ai/test", { method: "POST" });
// v1.4: pass the *current* dropdown / input selection so the test
// honours unsaved changes. Plaintext keys never persist server-side.
const overrideBody: Record<string, string> = {};
if (provider) overrideBody.provider = provider;
if (model.trim()) overrideBody.model = model.trim();
if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim();
if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim();
if (localKey.trim()) overrideBody.localKey = localKey.trim();
const hasOverride = Object.keys(overrideBody).length > 0;
const res = await fetch("/api/ai/test", {
method: "POST",
headers: hasOverride ? { "Content-Type": "application/json" } : {},
body: hasOverride ? JSON.stringify(overrideBody) : undefined,
});
const json = await res.json();
if (!res.ok) {
setTestMsg(json.error || `HTTP ${res.status}`);
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { EmptyState } from "../empty-state";
describe("<EmptyState>", () => {
it("renders title, description, and action inside a polite live region", () => {
const html = renderToStaticMarkup(
<EmptyState
title="Noch keine Messungen"
description="Lege deine erste Messung an."
action={<button type="button">Messung anlegen</button>}
/>,
);
expect(html).toContain("Noch keine Messungen");
expect(html).toContain("Lege deine erste Messung an.");
expect(html).toContain("Messung anlegen");
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
});
it("hides the decorative icon from assistive tech", () => {
const html = renderToStaticMarkup(
<EmptyState icon={<svg data-testid="icon" />} title="leer" />,
);
// The icon's wrapper carries aria-hidden so the icon doesn't
// double-announce alongside the title.
expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]*<svg/);
});
it("compact variant tightens spacing", () => {
const def = renderToStaticMarkup(<EmptyState title="default" />);
const compact = renderToStaticMarkup(
<EmptyState title="compact" size="compact" />,
);
expect(def).toContain("py-8");
expect(compact).toContain("py-4");
expect(compact).not.toContain("py-8");
});
it("plain variant drops the dashed wrapper", () => {
const card = renderToStaticMarkup(<EmptyState title="x" />);
const plain = renderToStaticMarkup(
<EmptyState title="x" variant="plain" />,
);
expect(card).toContain("border-dashed");
expect(plain).not.toContain("border-dashed");
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Input } from "../input";
describe("<Input>", () => {
it("defaults autoComplete to off and tells password managers to skip", () => {
const html = renderToStaticMarkup(<Input id="weight" />);
expect(html).toContain('autoComplete="off"');
expect(html).toContain('data-lpignore="true"');
expect(html).toContain('data-1p-ignore="true"');
});
it("respects an explicit autoComplete value and drops the ignore attrs", () => {
const html = renderToStaticMarkup(
<Input id="email" type="email" autoComplete="email" />,
);
expect(html).toContain('autoComplete="email"');
expect(html).not.toContain('data-lpignore="true"');
expect(html).not.toContain('data-1p-ignore="true"');
});
it("explicit autoComplete=username keeps password manager active for login flows", () => {
const html = renderToStaticMarkup(
<Input id="username" autoComplete="username" />,
);
expect(html).toContain('autoComplete="username"');
expect(html).not.toContain("lpignore");
expect(html).not.toContain("1p-ignore");
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Skeleton } from "../skeleton";
describe("<Skeleton>", () => {
it("renders a presentation node so screen readers skip it", () => {
const html = renderToStaticMarkup(<Skeleton className="h-4 w-32" />);
expect(html).toContain('role="presentation"');
expect(html).toContain('aria-hidden="true"');
});
it("preserves caller classes alongside the pulse animation", () => {
const html = renderToStaticMarkup(
<Skeleton className="h-8 rounded-full" />,
);
// The pulse class drives the visual shimmer; honour reduce-motion.
expect(html).toContain("animate-pulse");
expect(html).toContain("motion-reduce:animate-none");
// Caller's classes should survive the merge.
expect(html).toContain("h-8");
expect(html).toContain("rounded-full");
});
});
+118
View File
@@ -0,0 +1,118 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Reusable empty-state for lists, tiles, and chart panels that have no
* data yet. Always pair an icon, a one-sentence explanation, and a
* single primary action. Mounts inside the same container the loaded
* UI uses so the layout does not shift on first data.
*
* Pattern (per `docs/ui-guidelines.md` §4.2):
*
* <EmptyState
* icon={<Plus className="size-6" />}
* title={t("empty.measurements.title")}
* description={t("empty.measurements.description")}
* action={<Button>{t("empty.measurements.add")}</Button>}
* />
*/
export interface EmptyStateProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"title"
> {
icon?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
/**
* `"card"` (default) wraps the empty-state in a bordered surface so
* it sits visually inside a list/tile.
* `"plain"` renders inline without the wrapper — for use inside
* chart panels that already have their own card.
*/
variant?: "card" | "plain";
/**
* Compact density for use inside tiles or table-row empty rows.
*/
size?: "default" | "compact";
}
function EmptyState({
icon,
title,
description,
action,
variant = "card",
size = "default",
className,
...props
}: EmptyStateProps) {
const inner = (
<div
className={cn(
"flex flex-col items-center justify-center text-center",
size === "compact" ? "gap-2 py-4" : "gap-3 py-8",
)}
>
{icon ? (
<div
aria-hidden="true"
className={cn(
"text-muted-foreground bg-muted/60 flex items-center justify-center rounded-full",
size === "compact" ? "size-8 p-1.5" : "size-12 p-2.5",
)}
>
{icon}
</div>
) : null}
<div className="space-y-1">
<p
className={cn(
"font-medium",
size === "compact" ? "text-sm" : "text-base",
)}
>
{title}
</p>
{description ? (
<p className="text-muted-foreground max-w-xs text-sm">
{description}
</p>
) : null}
</div>
{action ? <div className="mt-1">{action}</div> : null}
</div>
);
if (variant === "plain") {
return (
<div
data-slot="empty-state"
role="status"
aria-live="polite"
className={cn("w-full", className)}
{...props}
>
{inner}
</div>
);
}
return (
<div
data-slot="empty-state"
role="status"
aria-live="polite"
className={cn(
"border-border bg-card rounded-lg border border-dashed",
className,
)}
{...props}
>
{inner}
</div>
);
}
export { EmptyState };
+30 -1
View File
@@ -2,11 +2,40 @@ import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
/**
* Base input primitive.
*
* v1.4 hardening: defaults `autoComplete` to `"off"` and tells
* LastPass / 1Password to skip the field when the caller does not
* opt in to a semantic autocomplete value. HealthLog is a health-data
* app — letting browsers / password managers autofill measurement,
* medication, mood, and AI-token inputs with the user's last-saved
* email or account password is at best confusing and at worst leaks
* credentials into a free-text field that gets persisted server-side.
*
* Auth and profile forms (login, register, change-email) keep working
* because they already pass an explicit `autoComplete` value —
* `"username"`, `"email"`, `"current-password"`, `"new-password"`. When
* `autoComplete` is anything other than `"off"`, the LastPass /
* 1Password ignore attrs are dropped so password managers fill the
* field normally.
*/
function Input({
className,
type,
autoComplete,
...props
}: React.ComponentProps<"input">) {
const resolvedAutoComplete = autoComplete ?? "off";
const skipAutofill = resolvedAutoComplete === "off";
return (
<input
type={type}
data-slot="input"
autoComplete={resolvedAutoComplete}
data-lpignore={skipAutofill ? "true" : undefined}
data-1p-ignore={skipAutofill ? "true" : undefined}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Pulsing placeholder used while data is loading. Wrap text-equivalent
* shapes so the page does not jump when real content arrives. Honours
* `prefers-reduced-motion` automatically because Tailwind's
* `animate-pulse` is disabled by `motion-reduce:animate-none`.
*
* Pattern: render the same layout the loaded UI will render, with each
* dynamic block replaced by a `<Skeleton className="h-X w-Y" />`.
*/
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="skeleton"
role="presentation"
aria-hidden="true"
className={cn(
"bg-muted/60 animate-pulse rounded-md motion-reduce:animate-none",
className,
)}
{...props}
/>
);
}
export { Skeleton };
+136 -7
View File
@@ -5,14 +5,21 @@ import { CodexClient } from "./codex-client";
import { OpenAIClient } from "./openai-client";
import { AnthropicClient } from "./anthropic-client";
import { LocalOpenAICompatibleClient } from "./local-client";
import { refreshAccessToken, encryptTokens, decryptTokens } from "./codex-oauth";
import {
refreshAccessToken,
encryptTokens,
decryptTokens,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
class NoProvider implements AIProvider {
readonly type = "none" as const;
async generateCompletion(_params: CompletionParams): Promise<CompletionResult> {
async generateCompletion(
_params: CompletionParams,
): Promise<CompletionResult> {
throw new Error(
"No AI provider configured. Connect ChatGPT or set an API key in settings.",
);
@@ -52,7 +59,9 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
case "LOCAL": {
if (!row.aiBaseUrl) return null;
return new LocalOpenAICompatibleClient({
apiKey: row.aiLocalKeyEncrypted ? decrypt(row.aiLocalKeyEncrypted) : null,
apiKey: row.aiLocalKeyEncrypted
? decrypt(row.aiLocalKeyEncrypted)
: null,
model: row.aiModel ?? "local-model",
baseUrl: row.aiBaseUrl,
});
@@ -71,7 +80,9 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
}
async function resolveAdminProvider(): Promise<AIProvider> {
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (settings?.adminAiKeyEncrypted) {
return new OpenAIClient({
@@ -84,7 +95,9 @@ async function resolveAdminProvider(): Promise<AIProvider> {
return new NoProvider();
}
async function resolveCodexProvider(userId: string): Promise<AIProvider | null> {
async function resolveCodexProvider(
userId: string,
): Promise<AIProvider | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -207,8 +220,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
// 2. Codex OAuth (either explicitly selected or implicit fallback)
const explicitChoice = userRow?.aiProvider?.toUpperCase();
const tryCodex =
explicitChoice === "CHATGPT_OAUTH" || !explicitChoice;
const tryCodex = explicitChoice === "CHATGPT_OAUTH" || !explicitChoice;
if (tryCodex) {
const codex = await resolveCodexProvider(userId);
if (codex) return codex;
@@ -217,3 +229,120 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
// 3. Admin OpenAI key (also acts as fallback for user-OPENAI selection)
return resolveAdminProvider();
}
/**
* Override that the connection-test endpoint accepts so the user can
* verify a provider config they have NOT saved yet (dropdown change → test
* before commit). Plaintext keys never persist.
*/
export type AITestOverride = {
provider?: string | null;
model?: string | null;
baseUrl?: string | null;
anthropicKey?: string | null;
localKey?: string | null;
};
export class AITestConfigError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "AITestConfigError";
this.status = status;
}
}
/**
* Resolve the provider for `/api/ai/test`. Falls back to the persisted user
* config when the matching override field is empty, so a user with a stored
* Anthropic key can change the model in the dropdown and test it without
* re-typing the key. The base URL still goes through the SSRF guard.
*/
export async function resolveProviderForTest(
userId: string,
override: AITestOverride = {},
): Promise<AIProvider> {
const stored = await prisma.user.findUnique({
where: { id: userId },
select: {
aiProvider: true,
aiModel: true,
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
},
});
const provider = (override.provider ?? stored?.aiProvider ?? "")
.toString()
.trim()
.toUpperCase();
const model = (override.model ?? stored?.aiModel ?? "").toString().trim();
const baseUrl = (override.baseUrl ?? stored?.aiBaseUrl ?? "")
.toString()
.trim();
// Empty selection: fall back to Codex → admin OpenAI like the regular path.
if (!provider) {
const codex = await resolveCodexProvider(userId);
if (codex) return codex;
return resolveAdminProvider();
}
switch (provider) {
case "ANTHROPIC": {
const apiKey =
override.anthropicKey?.trim() ||
(stored?.aiAnthropicKeyEncrypted
? decrypt(stored.aiAnthropicKeyEncrypted)
: "");
if (!apiKey) {
throw new AITestConfigError(422, "Anthropic API key not configured");
}
return new AnthropicClient({
apiKey,
model: model || "claude-3-5-sonnet-latest",
baseUrl: baseUrl || undefined,
});
}
case "LOCAL": {
if (!baseUrl) {
throw new AITestConfigError(422, "Local provider requires a base URL");
}
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
if (!allowPrivate && !isPublicUrl(baseUrl)) {
throw new AITestConfigError(
422,
"Base URL points to an internal/private host",
);
}
const apiKey =
override.localKey?.trim() ||
(stored?.aiLocalKeyEncrypted
? decrypt(stored.aiLocalKeyEncrypted)
: null);
return new LocalOpenAICompatibleClient({
apiKey,
model: model || "local-model",
baseUrl,
});
}
case "CHATGPT_OAUTH": {
const codex = await resolveCodexProvider(userId);
if (codex) return codex;
throw new AITestConfigError(422, "ChatGPT OAuth is not connected");
}
case "OPENAI": {
const admin = await resolveAdminProvider();
if (admin.type === "none") {
throw new AITestConfigError(
422,
"Admin OpenAI key is not configured for this instance",
);
}
return admin;
}
default:
throw new AITestConfigError(422, `Unknown provider: ${provider}`);
}
}