mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
No files matched your search
@@ -104,3 +104,41 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# LOKI_ENDPOINT="http://loki:3100"
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Process split (optional, single-container deploys ignore this)
|
||||
# -----------------------------------------------------------------------------
|
||||
# `all` — single container runs HTTP + reminder worker (default, legacy).
|
||||
# `web` — HTTP only; pair with a separate `app-worker` service.
|
||||
# `worker`— pg-boss only; rejects HTTP traffic with a 503.
|
||||
# See docs/self-hosting/scaling.md for the split-stack guide.
|
||||
# HEALTHLOG_PROCESS_TYPE="all"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Encryption-key versioning (optional — for at-rest key rotation)
|
||||
# -----------------------------------------------------------------------------
|
||||
# JSON map of key id → 64-hex-char or base64 32-byte key. When set, it
|
||||
# supersedes ENCRYPTION_KEY for new writes; old rows continue to decrypt under
|
||||
# their original id. ENCRYPTION_ACTIVE_KEY_ID picks which entry encrypt() uses.
|
||||
# Run `pnpm tsx scripts/rotate-encryption-key.ts <new-id>` to re-encrypt every
|
||||
# existing row under the new key. See docs/ops/encryption-key-rotation.md.
|
||||
# ENCRYPTION_KEYS='{"v1":"<old-key>","v2":"<new-key>"}'
|
||||
# ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Off-host backup (optional — daily encrypted dumps to an S3-compatible bucket)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required ALL-OR-NONE: setting any of these without setting the rest disables
|
||||
# the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron.
|
||||
# Restore with `pnpm tsx scripts/restore-backup.ts <userId> <YYYY-MM-DD>`.
|
||||
# See docs/ops/backup-restore.md for the format spec + step-by-step.
|
||||
# BACKUP_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
|
||||
# BACKUP_S3_BUCKET="healthlog-backups"
|
||||
# BACKUP_S3_ACCESS_KEY=""
|
||||
# BACKUP_S3_SECRET_KEY=""
|
||||
# BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3
|
||||
# BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule)
|
||||
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "shadcn@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,16 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: v1.3.3 — Pulse oximetry (SpO₂) as a first-class measurement type, layered on top of v1.3.2 body composition (TBW + Bone Mass). SSRF-hardened outbound fetches (now also covers Web-Push endpoint + Bearer-scope wildcard handling + IP-geolocation HTTPS-only), GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain + audit-log retention purge (GDPR Art. 5(1)(e)), blocking TypeScript CI, locale-integrity test guard. moodLog webhook secret now AES-GCM encrypted at rest. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3).
|
||||
**Status**: v1.4.0 — UI guidelines + reusable Skeleton/EmptyState
|
||||
primitives, medical citations consolidated under
|
||||
`src/lib/medical-citations.ts` (BP_DIA hypotension floor, ESH 2023
|
||||
alignment, "WHO 8000 steps" hallucination removed, ACE body-fat bands
|
||||
corrected), localised medication reminders + dashboard greeting +
|
||||
Zod validations, two more N+1 queries closed, Berlin-TZ-aware weekly
|
||||
buckets, single-row dashboard tile strip, public `/api/version`
|
||||
endpoint, AI provider connection-test honours unsaved selection,
|
||||
health-data inputs block password-manager autofill by default. See
|
||||
GitHub Releases + CHANGELOG.md for the full feature timeline.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
+248
@@ -1,5 +1,253 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.0] — 2026-05-08
|
||||
|
||||
### Added — Foundation, safer ranges, and a faster dashboard
|
||||
|
||||
- **UI guidelines, design tokens, and shared primitives.** A new
|
||||
`docs/ui-guidelines.md` is the single source of truth for spacing,
|
||||
typography, button hierarchy, dialog-vs-sheet decisions, accessibility
|
||||
baseline (WCAG 2.1 AA), and the autofill / honeypot pattern for
|
||||
health-data forms. Two new shadcn primitives — `<Skeleton>` and
|
||||
`<EmptyState>` — replace the previous mix of spinners and "No data"
|
||||
placeholder strings. Future v1.4.x components reference the doc; the
|
||||
primitives ship with screen-reader-aware semantics and respect
|
||||
`prefers-reduced-motion`.
|
||||
- **`/api/version` public endpoint** exposing the build's version,
|
||||
optional Git SHA / build timestamp, license, and canonical links.
|
||||
Wires the future Settings → About surface and a thin "Check for
|
||||
updates" UX. Static-cached so the route adds zero DB load.
|
||||
- **`src/lib/medical-citations.ts`** — single source of truth for
|
||||
cited medical guidelines (id, name, year, URL, caveat). Future
|
||||
medical surfaces import these constants instead of duplicating
|
||||
strings in code, prompts, and `messages/*.json`. A new drift-test
|
||||
asserts every entry has a non-empty URL + caveat and that the
|
||||
recurring "WHO ≥ N steps" hallucination cannot reappear as a constant.
|
||||
|
||||
### Fixed — Patient safety and citation accuracy
|
||||
|
||||
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
|
||||
With the default age-based targets (DBP 70–79), the lower orange
|
||||
wing was computed as `diaLow − 10 = 60`. A reading of 60 mmHg landed
|
||||
in "mildly low" yellow instead of red even though that level is the
|
||||
general-adult hypotension threshold and the J-curve risk floor in
|
||||
ESH 2023 for treated hypertensives. Orange floor is now clamped at
|
||||
65 mmHg, so 60 mmHg lands in red. The user-override path stays
|
||||
intact and remains audit-logged.
|
||||
- **BP guideline citations consolidated on ESH 2023.** The codebase
|
||||
had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI
|
||||
prompts). The 2023 hypertension document is ESH-only — ESC withdrew
|
||||
from the joint authoring — so neither label was correct. Every site
|
||||
now cites "ESH 2023" with the published source URL. Numbers
|
||||
unchanged.
|
||||
- **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO
|
||||
publishes activity *time* (150–300 min/wk moderate), not a step
|
||||
quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI
|
||||
prompt strings and the `getStepsRange()` helper carried the old
|
||||
wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau
|
||||
8 000–12 000) is now cited everywhere and the two surfaces agree on
|
||||
the band. Sleep target moves from "ESC" (no adult sleep guideline)
|
||||
to AASM 2015.
|
||||
- **Body-fat ACE bands corrected and three-way drift resolved.** The
|
||||
classifier used `essential = 6 (M) / 14 (F)` as the floor — but
|
||||
that's actually ACE's *Athletes* lower bound. Readings below were
|
||||
mislabelled "Essential" instead of "Below essential" (a danger
|
||||
band). Six-band classifier now mirrors the ACE table, and the three
|
||||
sites that had three different green-band numbers
|
||||
(`value-bands.ts`, `targets/route.ts`, `classifications.ts`) all
|
||||
derive from `getBodyFatTargetRange` (ACE fitness + acceptable bands).
|
||||
- **Bedtime-glucose citation softened.** ADA Standards 2024 §6
|
||||
publishes pre-prandial 80–130 and post-prandial <180 — no published
|
||||
adult bedtime target. The 90–150 mg/dL band stays (reasonable adult
|
||||
overnight band) but the inline citation now states the absence
|
||||
explicitly and references ISPAD 2022 (pediatric) as the closest
|
||||
comparator.
|
||||
|
||||
### Fixed — Localisation reaches the notification path
|
||||
|
||||
- **Medication reminders now follow the user's locale.** Telegram,
|
||||
ntfy, and Web Push reminders previously read "Erinnerung", "Bald
|
||||
fällig", etc. regardless of the user's stored language. Templates
|
||||
for every phase (`green`/`yellow`/`orange`/`red`) and every keyboard
|
||||
button now resolve from `messages/{de,en}.json` per
|
||||
`med.user.locale`. Telegram callback IDs stay stable English
|
||||
identifiers so the dispatcher keeps matching across locale changes.
|
||||
- **Dashboard greeting and streak label** are localised server-side.
|
||||
Previously hard-coded `"Hi, ${name}"` and `"Tage in Folge"` — both
|
||||
now i18n-key-resolved.
|
||||
- **Mixed-locale Zod validation messages unified to English.** Two
|
||||
measurement-form messages and four admin-validation messages
|
||||
flipped between German and English depending on which schema fired.
|
||||
All consolidated on English (the app is English-first; the German
|
||||
UI maps field labels client-side).
|
||||
|
||||
### Fixed — Chart math edge cases
|
||||
|
||||
- **`summarize` and `trendSlope` use the same time anchor.** Averages
|
||||
snapped to `Date.now()`; slopes snapped to the latest point in the
|
||||
series. A stale series reported a trend even though the dashboard
|
||||
tile correctly hid the average. Both now anchor on `Date.now()`, so
|
||||
a stale series returns `null` consistently from every windowed stat.
|
||||
- **`summarize([])` returns `null` for `min`/`max`/`mean`** instead
|
||||
of zeros that leaked into chart axes and AI feature bundles as
|
||||
fake readings.
|
||||
- **`weeklyAverages` is Berlin-timezone aware.** A Sunday-evening
|
||||
Berlin reading bucketed into the next week on the UTC production
|
||||
container because `Date.getDay()` was system-local. ISO-Monday key
|
||||
now resolves via `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`.
|
||||
- **`pairByTimestamp` JSDoc** documents the greedy nearest-match
|
||||
heuristic and when a Hungarian-style match would matter (sparse
|
||||
health data is well below that bar).
|
||||
|
||||
### Fixed — Hidden friction
|
||||
|
||||
- **AI provider connection-test honours the unsaved selection.**
|
||||
Changing the AI provider in `/settings`, then clicking "Verbindung
|
||||
testen" without saving first, used to silently run the test against
|
||||
the stored provider — surfacing as a confusing OK / failure unrelated
|
||||
to what the user had on screen. Plaintext keys never persist; the
|
||||
existing SSRF guard, rate limit, and V3 error-leak shielding stay in
|
||||
place.
|
||||
- **Health-data inputs no longer autofill the user's account
|
||||
password.** The base `<Input>` primitive defaults to
|
||||
`autoComplete="off"` plus the LastPass / 1Password ignore attributes
|
||||
whenever the caller doesn't pass a semantic value. Auth and profile
|
||||
forms continue to autofill normally because they pass an explicit
|
||||
`autoComplete` (`"username"`, `"email"`, `"current-password"`,
|
||||
`"new-password"`).
|
||||
- **Step-range target aligned across two callsites.**
|
||||
`getStepsRange()` returned `{7000, 10000}` while
|
||||
`effective-range.ts` returned `{8000, 15000}`; two surfaces showed
|
||||
different "green" bands to the same user. Both now use
|
||||
`{8000, 15000}`, anchored on Saint-Maurice 2020.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Two more N+1 queries closed.** `extractFeatures` (used by every
|
||||
AI-insight route) issued one `prisma.medicationIntakeEvent.findMany`
|
||||
per active medication; replaced with a single batched query and an
|
||||
in-memory group. `/api/insights/targets` issued one `findFirst` per
|
||||
measurement type; replaced with a single `distinct: ["type"]` query.
|
||||
Same shape as the v1.3.0 fix to `/api/insights/comprehensive`.
|
||||
|
||||
### Changed — Dashboard
|
||||
|
||||
- **Tile strip is always one row.** Replaces the wrapping
|
||||
`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5` layout
|
||||
with a `flex snap-x snap-mandatory overflow-x-auto` strip. When the
|
||||
user enables more tiles than fit the viewport, the strip
|
||||
horizontal-scrolls instead of wrapping; the user trims the set in
|
||||
Settings → Dashboard Layout.
|
||||
|
||||
### Added — Settings, integrations, and operations
|
||||
|
||||
- **Settings page now lives at `/settings/[section]`.** Eight focused
|
||||
routes (`account`, `integrations`, `notifications`, `dashboard`,
|
||||
`ai`, `api`, `advanced`, `about`) replace the single 3,000-line page.
|
||||
Existing `/settings#anchor` links 308-redirect; the side-bar, in-app
|
||||
deep links, and the AI / Withings / Codex callbacks all follow the
|
||||
new structure.
|
||||
- **About page** lists the running version, build SHA, license,
|
||||
repository link, CHANGELOG link, docs link, and a "Check for
|
||||
updates" button that pings the public GitHub releases API. Backed by
|
||||
the `/api/version` endpoint shipped earlier in 1.4.0.
|
||||
- **Admin console** is built around a status-first card grid (Users,
|
||||
Integrations, Monitoring, Backups, Maintenance, Audit Log) with each
|
||||
area in a focused panel beneath. Per-section extraction of the old
|
||||
inline panels is tracked for v1.4.1 — the v1.4.0 admin page already
|
||||
routes through the new aggregator endpoint and the status-card
|
||||
grid.
|
||||
- **Five new "Test connection" buttons in Settings.** Withings,
|
||||
moodLog, Web Push, Glitchtip, and Umami now ship with one-click
|
||||
connection probes — same pattern as the existing AI / Telegram /
|
||||
ntfy tests, with per-button rate limit, sanitised error reporting,
|
||||
redirect-follow SSRF guard, and an `errorCode` in the response
|
||||
envelope so the UI can localise the message.
|
||||
- **AI insights can reference any of your charts inline.** When a
|
||||
finding centres on a single metric (e.g. systolic blood pressure),
|
||||
the corresponding chart renders directly under the explanation.
|
||||
Server-side allow-list — only the allowed metric tokens render; any
|
||||
other model emission drops silently.
|
||||
- **Off-host backup target.** Daily encrypted JSON dumps to any
|
||||
S3-compatible bucket. Worker-side IAM grant is intentionally
|
||||
PutObject + GetObject only — retention is the bucket's
|
||||
lifecycle-rule job, so a compromised worker cannot wipe the backup
|
||||
history. Restore script + step-by-step doc shipped under
|
||||
`docs/ops/backup-restore.md`, and an admin "Backup target" test
|
||||
button validates the configuration.
|
||||
- **Encryption-key versioning.** Rotate the at-rest encryption key
|
||||
without downtime via `pnpm tsx scripts/rotate-encryption-key.ts <id>`.
|
||||
Existing data keeps decrypting under its original key while the new
|
||||
one is rolled out. Walk-through + rollback notes in
|
||||
`docs/ops/encryption-key-rotation.md`.
|
||||
- **Worker / web split.** Optional
|
||||
`HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all` for the
|
||||
single-container setup) lets you scale background jobs and HTTP
|
||||
traffic independently. The proxy refuses HTTP traffic with a 503 +
|
||||
`X-HealthLog-Process-Type: worker` header in worker mode so a
|
||||
misrouted request fails loudly instead of a silent half-served
|
||||
response.
|
||||
- **Native API clients now get short-lived 24-hour access tokens with
|
||||
refresh-token rotation.** The browser keeps the existing 90-day
|
||||
Bearer. Reuse-detection (presenting a refresh token a second time)
|
||||
revokes every refresh token for the user — the small cost of a
|
||||
forced re-login on the legitimate device buys defense-in-depth
|
||||
against an undetected stolen-token replay.
|
||||
- **Critical-path coverage on Telegram / Withings / moodLog /
|
||||
Glitchtip webhook handlers + the four admin routes lifted to ≥80%
|
||||
line coverage,** plus `src/lib/auth/audit.ts`. ~+100 new tests.
|
||||
|
||||
### Fixed — Operational hardening from the v1.4 review pass
|
||||
|
||||
- **Container time zone is correct.** Alpine images ship without
|
||||
`tzdata`; the daily backup cron `30 2 * * *` Europe/Berlin was
|
||||
silently falling back to UTC. The runner stage now installs
|
||||
`tzdata` and exports `TZ=Europe/Berlin` so schedules fire at the
|
||||
documented local time.
|
||||
- **Compose healthcheck uses `wget --spider /api/version`** — `/api/version`
|
||||
is now in the proxy's public-paths allowlist, so the healthcheck no
|
||||
longer 302-redirects through the auth gate (which was accepting the
|
||||
login page as a 200 success).
|
||||
- **Idempotency replay-cache no longer caches refresh tokens.** The
|
||||
guard already blocked the `hlk_` access-token prefix; the new
|
||||
`hlr_` refresh tokens are blocked too.
|
||||
- **Logout-on-device revokes the paired access token.** Calling
|
||||
`/api/auth/refresh` with `revoke: true` now flips both the refresh
|
||||
row and the matching `ApiToken` row to revoked, so a leaked access
|
||||
token cannot outlive its refresh-token sibling.
|
||||
- **`users.locale` migration drift backfilled.** The column had been
|
||||
on `schema.prisma` since the v1.3 locale-aware reminder work but
|
||||
never landed in the migration history (it must have been applied
|
||||
via `prisma db push` to dev/prod). Any environment built strictly
|
||||
from `prisma/migrations/` (CI testcontainers, brand-new self-host
|
||||
installs) is now consistent. Migration is `ADD COLUMN IF NOT
|
||||
EXISTS`, so it's a clean add on a fresh database and a safe no-op
|
||||
against any environment that was already kept in sync.
|
||||
|
||||
### Notes
|
||||
|
||||
- Largely additive release. Existing API contracts (response
|
||||
envelopes, OpenAPI 3.1 spec) are unchanged. New endpoints surface
|
||||
optional fields; no breaking changes.
|
||||
- New migration `0025_refresh_tokens` adds the rotating refresh-token
|
||||
table; new migration `0025_user_locale_drift_fix` backfills the
|
||||
schema-vs-migrations drift on `users.locale`. Both are
|
||||
forward-compatible — `IF NOT EXISTS` guards make them idempotent on
|
||||
any environment already pushed-to.
|
||||
- Operators of the off-host backup feature must configure a bucket
|
||||
lifecycle policy for retention. The worker has no DeleteObject
|
||||
grant by design.
|
||||
- Native API clients (iOS, n8n, Health Connect) need to update their
|
||||
login flow: native logins now return both a 24-hour access token
|
||||
and a refresh token. The browser flow is unchanged.
|
||||
- **Tracked for v1.4.1:** per-section admin panel extraction (the
|
||||
status-card grid + aggregator already ship in 1.4.0; the inner
|
||||
per-section file split is structural cleanup), the Postgres-backed
|
||||
integration test suite (testcontainers infrastructure ships in this
|
||||
release; the four integration tests themselves need a follow-up
|
||||
pass against the merged schema), and Playwright E2E + axe-core CI
|
||||
gates.
|
||||
|
||||
## [1.3.3] — 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
+6
-1
@@ -23,12 +23,17 @@ RUN pnpm build
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
|
||||
# timestamp formatting) resolve to the actual offset instead of silently
|
||||
# falling back to UTC on Alpine images that ship without it.
|
||||
RUN apk add --no-cache tzdata && \
|
||||
corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
+57
-11
@@ -1,12 +1,10 @@
|
||||
services:
|
||||
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
|
||||
# self-host deployments stay working with `docker compose up`.
|
||||
# For horizontal scaling switch to the web/worker split below
|
||||
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
||||
app:
|
||||
# Pre-built multi-arch images are published to GHCR by
|
||||
# .github/workflows/docker-publish.yml on every push to main and on
|
||||
# tagged releases. Pin to a specific version in production:
|
||||
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Fallback build context: if the image is not yet in the registry,
|
||||
# or you want to test local changes, run `docker compose up --build`.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -14,22 +12,70 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
|
||||
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
|
||||
# eagerly and store the *fallback error string* as the literal value
|
||||
# when the env var is unset, which then breaks the deployment.
|
||||
# Validation is enforced by docker-entrypoint.sh before the app starts.
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://localhost:3000/api/version || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Optional dedicated worker container — uncomment + set
|
||||
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
|
||||
# See docs/self-hosting/scaling.md for the full guide.
|
||||
#
|
||||
# app-worker:
|
||||
# image: ghcr.io/mbombeck/healthlog:latest
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: healthlog-worker
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# NODE_ENV: production
|
||||
# SESSION_SECRET: "${SESSION_SECRET}"
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
# APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||||
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.1.0
|
||||
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.3.3
|
||||
version: 1.4.0
|
||||
description: |
|
||||
REST API for HealthLog — a personal health-tracking PWA covering weight,
|
||||
blood pressure, pulse, mood, medication compliance, blood glucose, body
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Migrating from HealthLog 1.3 to 1.4
|
||||
|
||||
Both 1.3.x and 1.4.0 use Prisma migrations 0001–0024 and the same
|
||||
PostgreSQL schema. The upgrade is **drop-in for self-hosters running
|
||||
the GHCR image** — no manual migration step beyond the standard
|
||||
`docker compose pull && docker compose up -d`. This guide documents
|
||||
what changes for operators and integrators, in case you're extending
|
||||
the API.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
That's it. No DB migration to apply, no env vars to add, no breaking
|
||||
API change.
|
||||
|
||||
## Database
|
||||
|
||||
- Prisma schema unchanged from 1.3.3. Migrations 0001–0024 remain
|
||||
authoritative. v1.4.0 brings no new migration files.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- **`NEXT_PUBLIC_APP_BUILD_SHA`** *(optional, new)* — the short Git
|
||||
SHA of the build, surfaced through `/api/version` so the future
|
||||
About surface can render it. The `docker-publish` workflow can wire
|
||||
this once the About UI lands.
|
||||
- **`NEXT_PUBLIC_APP_BUILT_AT`** *(optional, new)* — ISO-8601 build
|
||||
timestamp, same plumbing.
|
||||
|
||||
Neither is required. Local `pnpm dev` returns `null` for both fields
|
||||
and the UI falls back to "development" wording.
|
||||
|
||||
## API
|
||||
|
||||
### New
|
||||
|
||||
- **`GET /api/version`** — public, static, returns
|
||||
`{ version, buildSha, builtAt, license, repository, changelog, docs }`.
|
||||
No authentication.
|
||||
|
||||
### Behaviour-only changes
|
||||
|
||||
- **`POST /api/ai/test`** now accepts an optional JSON body that
|
||||
mirrors the AI dropdown in `/settings`. Empty body keeps the
|
||||
pre-1.4 behaviour (test the saved provider). Non-empty body tests
|
||||
the unsaved selection without persisting plaintext keys. The
|
||||
existing rate limit (5/min) and error-leak shielding stay in place.
|
||||
- **`GET /api/dashboard/summary`** localises the `streak.label` and
|
||||
`greeting.salutation` fields against the user's stored locale.
|
||||
- **`POST /api/insights/targets`** uses a single batched query for
|
||||
per-type latest readings; returns identical payloads.
|
||||
|
||||
No `/api/...` route changed shape, status codes, or auth rules. iOS,
|
||||
n8n, and Health Connect integrations need no change.
|
||||
|
||||
## UI / UX
|
||||
|
||||
### Dashboard
|
||||
|
||||
- **Tile strip is always one row.** Previously the four-to-five-tile
|
||||
grid wrapped to a second row when many widgets were enabled. v1.4
|
||||
uses a horizontal-scroll strip — total width caps at the chart
|
||||
width below, snap points keep the touch experience deliberate. To
|
||||
reduce the visible tile count, open Settings → Dashboard Layout
|
||||
and toggle widgets off.
|
||||
|
||||
### Forms
|
||||
|
||||
- **Password managers no longer autofill health-data inputs.**
|
||||
Browsers and LastPass / 1Password were happily filling the user's
|
||||
saved account password into measurement notes, AI tokens, and
|
||||
similar free-text fields. The `<Input>` primitive now defaults to
|
||||
`autoComplete="off"` plus the LastPass / 1Password ignore
|
||||
attributes whenever the caller doesn't pass a semantic value. Auth
|
||||
and profile forms (login, register, change-email) keep autofilling
|
||||
normally because they pass an explicit `autoComplete` (e.g.
|
||||
`"username"`, `"email"`, `"new-password"`).
|
||||
|
||||
### Settings
|
||||
|
||||
- **AI provider connection-test honours the unsaved selection.** Pick
|
||||
a provider in the dropdown and click "Verbindung testen" without
|
||||
saving first — the test now runs against your in-form selection
|
||||
instead of the saved provider.
|
||||
|
||||
### Notifications
|
||||
|
||||
- **Telegram, ntfy, and Web Push reminders follow your locale.**
|
||||
Switching the user's locale to English now produces English
|
||||
reminders; the previous build always sent German strings regardless.
|
||||
Telegram callback IDs stay stable (English) so the bot dispatcher
|
||||
keeps matching them.
|
||||
|
||||
## Medical defaults
|
||||
|
||||
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
|
||||
A reading of 60 mmHg now lands in red instead of yellow. The
|
||||
user-override path is intact — if you set a personal threshold via
|
||||
`/settings#thresholds`, it still applies and is audit-logged.
|
||||
- **Body-fat classifier mirrors the ACE table** — readings below the
|
||||
ACE essential floor (M < 2 / F < 10) now classify as
|
||||
"Below essential" (danger), the actual essential band (M 2–5 /
|
||||
F 10–13) as warning, and the realistic healthy range
|
||||
(M 14–24 / F 21–31) as the green band on the dashboard target tile.
|
||||
- **Step target unified at 8 000–15 000.** The two surfaces that
|
||||
previously disagreed (7 000–10 000 in `getStepsRange()` versus
|
||||
8 000–15 000 in the effective-range resolver) now use the same
|
||||
band, anchored on Saint-Maurice et al., JAMA 2020.
|
||||
|
||||
## What's deferred to a future release
|
||||
|
||||
The v1.3.3 ecosystem audit identified four architectural rewrites that
|
||||
are intentionally outside v1.4.0:
|
||||
|
||||
- Settings page split into per-section routes (`/settings/[section]`).
|
||||
- Admin page redesign with status-first card grid.
|
||||
- AI insights rework with severity-coloured key-findings hero and
|
||||
dynamic chart inclusion via an allowlist token map.
|
||||
- Multi-tenant prep (off-host backups, encryption-key versioning,
|
||||
worker/web container split, native-Bearer User-Agent gating).
|
||||
- Postgres-backed integration test suite (Testcontainers).
|
||||
|
||||
These ride a future release; the v1.4 marathon closed every CRIT and
|
||||
HIGH the audit identified except those four.
|
||||
|
||||
## Rollback
|
||||
|
||||
If 1.4.0 surfaces a regression on your install, redeploy the previous
|
||||
1.3.3 image:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker pull ghcr.io/mbombeck/healthlog:1.3.3
|
||||
sed -i 's/healthlog:1.4.0/healthlog:1.3.3/' docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The schema is unchanged so a rollback does not require a database
|
||||
restore. File a GitHub issue with the symptom — the v1.4 cycle ran
|
||||
multi-agent QA per phase, but no migration is risk-free.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Off-host backup & restore (v1.4 G1)
|
||||
|
||||
HealthLog ships with an optional daily off-host backup that ships every
|
||||
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
|
||||
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
|
||||
AWS S3, Backblaze B2, MinIO, etc.
|
||||
|
||||
The backup runs at **02:30 Europe/Berlin** every day from the worker
|
||||
container (queue `data-backup-offhost`). Object key layout:
|
||||
|
||||
```
|
||||
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
|
||||
```
|
||||
|
||||
## Wire format (binary)
|
||||
|
||||
```
|
||||
magic = "HLBK" (4 bytes, ASCII)
|
||||
version = 0x01 (1 byte)
|
||||
iv = 12 random bytes (AES-GCM nonce)
|
||||
authTag = 16 bytes (AES-GCM tag)
|
||||
ciphertext = N bytes (AES-256-GCM, key = BACKUP_ENCRYPTION_KEY)
|
||||
plaintext = JSON dump (UTF-8)
|
||||
```
|
||||
|
||||
## Required env vars
|
||||
|
||||
| Var | Required | Notes |
|
||||
| ----------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
|
||||
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
|
||||
| `BACKUP_S3_BUCKET` | yes | |
|
||||
| `BACKUP_S3_ACCESS_KEY` | yes | |
|
||||
| `BACKUP_S3_SECRET_KEY` | yes | |
|
||||
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
|
||||
| `BACKUP_RETENTION_DAYS` | no | defaults to `30` |
|
||||
|
||||
## Bucket lifecycle (recommended)
|
||||
|
||||
The worker prunes objects older than `BACKUP_RETENTION_DAYS`, but the
|
||||
storage provider's lifecycle rule is the canonical safety net:
|
||||
|
||||
```
|
||||
Filter: "" (all objects)
|
||||
Action: Expire after 30 days
|
||||
```
|
||||
|
||||
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
|
||||
|
||||
## Smoke test
|
||||
|
||||
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
|
||||
performs a 1-byte PUT + GET round-trip and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://...r2.cloudflarestorage.com",
|
||||
"bucket": "healthlog-backups",
|
||||
"region": "auto",
|
||||
"putLatencyMs": 142,
|
||||
"getLatencyMs": 38,
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
The credentials are never returned.
|
||||
|
||||
## Restore
|
||||
|
||||
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
|
||||
and run:
|
||||
|
||||
```bash
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
|
||||
pnpm tsx scripts/restore-backup.ts \
|
||||
2026-05-08/user-clx123.json.enc \
|
||||
/tmp/restored.json
|
||||
```
|
||||
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Encryption-key rotation
|
||||
|
||||
HealthLog encrypts sensitive at-rest data (Withings tokens, AI provider keys,
|
||||
notification channel configs, web-push subscription secrets, VAPID private
|
||||
keys, etc.) with AES-256-GCM under a per-deployment key. v1.4 introduces a
|
||||
versioned key format so the key can be rotated without downtime and without
|
||||
re-encrypting every row by hand.
|
||||
|
||||
## Format
|
||||
|
||||
| Layout | Marker | Where |
|
||||
| --------------- | ----------------------------------------------- | --------------------------- |
|
||||
| Versioned (new) | `<keyId>.<base64(iv \|\| tag \|\| ciphertext)>` | All new writes |
|
||||
| Legacy (v1.3.x) | `<base64(iv \|\| tag \|\| ciphertext)>` | Existing rows until rotated |
|
||||
|
||||
`<keyId>` matches `[A-Za-z0-9_-]{1,32}` and indexes into the `ENCRYPTION_KEYS`
|
||||
JSON map. Decryption tries the versioned format first; if no `.` is present
|
||||
or the prefix isn't a known id, the row is treated as legacy and decrypted
|
||||
under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
|
||||
> **Why a separate `v1` is required.** Legacy ciphertexts have no key id, so
|
||||
> the only way to identify them is "no `.` in the value". If you remove the
|
||||
> `v1` entry from `ENCRYPTION_KEYS` _before_ every legacy row has been
|
||||
> rotated, those rows can't be decrypted any more — the active key won't
|
||||
> match the original ciphertext. The decrypt path now refuses to silently
|
||||
> fall back to the active key in that scenario; it throws a clear error.
|
||||
|
||||
## Rotating from v1.3.x to a new key
|
||||
|
||||
1. **Generate the new key** on a machine with `openssl`:
|
||||
```
|
||||
openssl rand -hex 32
|
||||
```
|
||||
2. **Update environment variables.** Keep the existing `ENCRYPTION_KEY` in
|
||||
place — it's still needed to decrypt legacy rows during the transition:
|
||||
```
|
||||
ENCRYPTION_KEY="<old key, unchanged>"
|
||||
ENCRYPTION_KEYS='{"v1":"<old key>","v2":"<new key>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
```
|
||||
Restart the app. New writes are now encrypted under `v2`; existing
|
||||
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
|
||||
retained.
|
||||
3. **Run the rotation script** to re-encrypt every existing encrypted column
|
||||
under the new active key:
|
||||
```
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
|
||||
NotificationChannel, and PushSubscription rows. The summary line at the
|
||||
end shows `scanned`, `rotated`, and `errors` per table+field; treat
|
||||
`errors > 0` as a hard failure and re-run after fixing the cause.
|
||||
4. **Drop the old key (optional, recommended).** Once the script has run
|
||||
cleanly:
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<new key>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
# ENCRYPTION_KEY can now be removed
|
||||
```
|
||||
Restart. The legacy single-key fallback is now disconnected; only `v2`
|
||||
exists.
|
||||
|
||||
## Adding a third key (v2 → v3)
|
||||
|
||||
Same procedure, just shift the labels — keep `v2` in the map until the
|
||||
script reports zero `v2.`-prefixed rows remaining.
|
||||
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v3
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
> **Important.** Once the rotation script has run, ciphertexts in the
|
||||
> database start with `v2.` (or whatever the active id is). The pre-PR
|
||||
> v1.3.x image cannot read that prefix — it expects bare base64 — and
|
||||
> calling `decrypt()` on those rows will throw.
|
||||
|
||||
If you need to revert to the pre-rotation image:
|
||||
|
||||
- Either keep the new image. The new code reads both formats, so most
|
||||
rollback scenarios don't need to undo rotation.
|
||||
- Or, if you must run the old code, restore a database backup taken
|
||||
_before_ the rotation script ran. There is no script to convert
|
||||
`v2.`-prefixed rows back to legacy format — by design, rotation is a
|
||||
forward-only operation.
|
||||
|
||||
This is why we recommend running the rotation in a window where you have a
|
||||
fresh DB snapshot and the new image has been smoke-tested in production for
|
||||
at least 24 hours.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Encryption key id 'v1' is not configured` — the database still contains
|
||||
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
|
||||
the key, run the rotation script, then remove again.
|
||||
- `Found a legacy-format ciphertext but no v1 key is configured` — same
|
||||
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
|
||||
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
|
||||
removing it.
|
||||
- `Refusing to rotate: argv key id ... does not match the currently active
|
||||
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
|
||||
guard prevents accidental re-encryption to a non-current key.
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 0025_refresh_tokens — down migration (v1.4 G4 rollback).
|
||||
-- Forward-compatible: the up migration only ADDS a table, so this is a
|
||||
-- safe DROP. Any in-flight refresh tokens are invalidated; native clients
|
||||
-- need to re-authenticate.
|
||||
|
||||
DROP TABLE IF EXISTS "refresh_tokens";
|
||||
@@ -0,0 +1,53 @@
|
||||
# Scaling: web/worker container split (v1.4 G3)
|
||||
|
||||
The default `docker compose up` launches a single container that runs
|
||||
both the Next.js HTTP server AND the pg-boss worker. That works fine
|
||||
for a personal deployment but is hard to scale horizontally because
|
||||
every replica also runs the full reminder/insight cron schedule.
|
||||
|
||||
v1.4 introduces an environment-variable gate so the same image can be
|
||||
deployed in three modes:
|
||||
|
||||
| `HEALTHLOG_PROCESS_TYPE` | Web | Worker | Use when |
|
||||
| ------------------------ | --- | ------ | ------------------------- |
|
||||
| `all` (default) | ✅ | ✅ | single-host self-hosting |
|
||||
| `web` | ✅ | ❌ | scaling HTTP horizontally |
|
||||
| `worker` | ❌ | ✅ | dedicated job worker |
|
||||
|
||||
## How to split
|
||||
|
||||
1. Edit `docker-compose.yml`: set `HEALTHLOG_PROCESS_TYPE=web` on the
|
||||
`app` service and uncomment the `app-worker` service block at the
|
||||
bottom (it ships with `HEALTHLOG_PROCESS_TYPE=worker` baked in).
|
||||
2. Make sure both containers see the same `DATABASE_URL`,
|
||||
`ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), `API_TOKEN_HMAC_KEY`,
|
||||
and `SESSION_SECRET`.
|
||||
3. `docker compose up -d --build`.
|
||||
|
||||
Both containers connect to the same Postgres. pg-boss claims jobs
|
||||
atomically via row-level locking, so it is safe to run multiple worker
|
||||
replicas — they will share the load.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- `app` (web) healthcheck: `wget /api/version` every 30s.
|
||||
- `app-worker` does not expose a port; its liveness is reported into
|
||||
the `worker_status` table on every reminder pass and surfaced through
|
||||
`/api/admin/worker-status`.
|
||||
|
||||
The web container does NOT depend on the worker, and vice versa, so
|
||||
neither container's startup can deadlock the other. The shared
|
||||
dependency is Postgres; both wait on `db: condition: service_healthy`.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Background tasks that touch user-scoped Wide Events (e.g. reminder
|
||||
notifications) emit telemetry from the worker; configure `LOKI_*`
|
||||
env vars on the worker if you want them.
|
||||
- The off-host backup job runs in the worker only. The admin
|
||||
`POST /api/admin/backup/test` endpoint runs in the web container and
|
||||
exercises the same S3 credentials — set `BACKUP_*` env vars on BOTH.
|
||||
- The startup gate (`assertSubsystemEnabled`) refuses to boot the
|
||||
reminder worker when `HEALTHLOG_PROCESS_TYPE=web`, so an accidental
|
||||
cross-mode invocation aborts immediately instead of silently doubling
|
||||
cron load.
|
||||
@@ -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._
|
||||
+165
-2
@@ -194,6 +194,8 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, willkommen zurück.",
|
||||
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "Tage in Folge",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"bloodPressureSys": "Blutdruck (Sys)",
|
||||
@@ -600,6 +602,10 @@
|
||||
"refreshAnalysis": "Analyse aktualisieren",
|
||||
"keyTakeaway": "Das Wichtigste",
|
||||
"findingsTitle": "Befunde",
|
||||
"heroFindingPositive": "Wichtigster Befund",
|
||||
"heroFindingNeutral": "Wichtigster Befund",
|
||||
"heroFindingAttention": "Beachten",
|
||||
"heroFindingWarning": "Wichtigster Alarm",
|
||||
"correlationsTitle": "Zusammenhänge",
|
||||
"recommendationsTitle": "Empfehlungen",
|
||||
"dataFoundation": "Datengrundlage",
|
||||
@@ -794,11 +800,119 @@
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen.",
|
||||
"v2": {
|
||||
"title": "Willkommen bei HealthLog",
|
||||
"subtitle": "Drei schnelle Schritte. Alles weitere kannst du später in den Einstellungen erledigen.",
|
||||
"stepOf": "Schritt {current} von {total}",
|
||||
"back": "Zurück",
|
||||
"continue": "Weiter",
|
||||
"finish": "Setup abschließen",
|
||||
"skipStep": "Schritt überspringen — du kannst das Setup später in den Einstellungen abschließen.",
|
||||
"step1": {
|
||||
"title": "Über dich",
|
||||
"description": "Wird für Blutdruck-Zielwerte, BMI und passende Erinnerungen genutzt.",
|
||||
"displayNameLabel": "Anzeigename",
|
||||
"displayNameHint": "Erscheint in der Dashboard-Begrüßung und in Berichten.",
|
||||
"languageLabel": "Sprache",
|
||||
"languageHint": "Wird für App, PDFs und Benachrichtigungen verwendet."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Lege deine erste Messung an",
|
||||
"description": "Gewicht, Blutdruck oder Puls — was du gerade zur Hand hast. Schon eine Messung lässt das Dashboard lebendig wirken.",
|
||||
"added": "Top — weitere Messungen kannst du jederzeit über das Dashboard hinzufügen.",
|
||||
"skipHint": "Keine Messung zur Hand? Überspringe und logge eine, wenn du soweit bist."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Wähle einen Benachrichtigungs-Kanal",
|
||||
"description": "Wir senden nur, was du auch willst. Du kannst weitere Kanäle und Events später feinjustieren.",
|
||||
"channelTelegramTitle": "Telegram-Bot",
|
||||
"channelTelegramHint": "Erinnerungen mit Snooze-Buttons direkt in Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native Browser-Benachrichtigungen auf diesem Gerät.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-Source-Push an Handy oder Desktop, ohne Account.",
|
||||
"channelNoneTitle": "Vorerst überspringen",
|
||||
"channelNoneHint": "Ich richte Benachrichtigungen später in den Einstellungen ein.",
|
||||
"openSettings": "In den Einstellungen fortsetzen"
|
||||
},
|
||||
"doneToast": "Alles eingerichtet — willkommen an Bord."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Erste Schritte",
|
||||
"subtitle": "Quick Wins, damit du das Beste aus HealthLog herausholst.",
|
||||
"progress": "{done} von {total} erledigt",
|
||||
"dismiss": "Ausblenden",
|
||||
"dismissAll": "Checkliste ausblenden",
|
||||
"dismissTooltip": "Diesen Punkt ausblenden",
|
||||
"restoreHint": "Du kannst die Checkliste in den Einstellungen wiederherstellen.",
|
||||
"items": {
|
||||
"profileTitle": "Profil einrichten",
|
||||
"profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.",
|
||||
"profileCta": "Profil öffnen",
|
||||
"measurementTitle": "Erste Messung anlegen",
|
||||
"measurementDescription": "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen.",
|
||||
"measurementCta": "Messung hinzufügen",
|
||||
"medicationTitle": "Medikament hinzufügen",
|
||||
"medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
|
||||
"medicationCta": "Medikament hinzufügen",
|
||||
"withingsTitle": "Withings verbinden (optional)",
|
||||
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
|
||||
"withingsCta": "Verbinden",
|
||||
"notificationsTitle": "Benachrichtigungen einrichten",
|
||||
"notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.",
|
||||
"notificationsCta": "Benachrichtigungen öffnen"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Erster Trend nach 5 Messungen",
|
||||
"remainingOne": "Noch 1 Messung bis zum Trend.",
|
||||
"remainingMany": "Noch {count} Messungen bis zum Trend."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Profil, Sicherheit & Verbindungen",
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"description": "Profil, Anmeldemethoden und Konto-Löschung."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrationen",
|
||||
"description": "Withings, moodLog und weitere verbundene Dienste."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"description": "Kachel-Layout, Schwellwerte."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Auswertungen",
|
||||
"description": "Provider, Modell, API-Key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer-Tokens für Drittanbieter-Clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweitert",
|
||||
"description": "Export, Import, Daten löschen."
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"description": "Version, Lizenz, Links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Wird mit dem nächsten Release-Update hierher umziehen."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryNotifications": "Benachrichtigungen",
|
||||
@@ -1039,7 +1153,41 @@
|
||||
"moodLogSyncResult": "{count} Einträge synchronisiert",
|
||||
"moodLogSyncFailed": "Sync fehlgeschlagen",
|
||||
"moodLogSaved": "moodLog-Verbindung gespeichert",
|
||||
"moodLogDisconnected": "moodLog getrennt"
|
||||
"moodLogDisconnected": "moodLog getrennt",
|
||||
"about": {
|
||||
"version": "App-Version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Erstellt {time}",
|
||||
"license": "Lizenz",
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"checkUpdates": "Auf Updates prüfen",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
"testing": "Teste…",
|
||||
"ok": "Verbunden (Latenz {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Zugangsdaten abgelehnt — Token prüfen",
|
||||
"rate_limited": "Vom Upstream rate-limitiert",
|
||||
"timeout": "Request hat das Zeitlimit überschritten",
|
||||
"upstream_error": "Upstream hat einen Fehler gemeldet",
|
||||
"connection_failed": "Verbindung fehlgeschlagen",
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"url_not_public": "URL ist kein öffentlicher Endpunkt",
|
||||
"url_invalid": "URL ist ungültig",
|
||||
"redirected": "Endpunkt leitet weiter — URL prüfen",
|
||||
"endpoint_not_found": "Endpunkt unter der URL nicht gefunden",
|
||||
"credentials_unreadable": "Zugangsdaten nicht entschlüsselbar",
|
||||
"upstream_invalid_json": "Upstream-Antwort ist kein JSON",
|
||||
"vapid_not_configured": "Web Push nicht konfiguriert (VAPID-Keys fehlen)",
|
||||
"rate_limited_self": "Zu viele Test-Anfragen",
|
||||
"generic": "Test fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -1629,5 +1777,20 @@
|
||||
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
"helpHeader": "<b>Verfügbare Befehle:</b>",
|
||||
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen <Name> — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
|
||||
},
|
||||
"medicationReminders": {
|
||||
"phaseGreenTitle": "🟢 Erinnerung: {medName}",
|
||||
"phaseGreenMessage": "🟢 Erinnerung:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nZeitfenster endet in {minutes} Min.",
|
||||
"phaseYellowTitle": "🟡 Bald fällig: {medName}",
|
||||
"phaseYellowMessage": "🟡 Bald fällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nNoch {minutes} Min. Zeit.",
|
||||
"phaseOrangeTitle": "🟠 Überfällig: {medName}",
|
||||
"phaseOrangeMessage": "🟠 Überfällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nSeit {minutes} Min. überfällig.",
|
||||
"phaseRedTitle": "🔴 Verpasst: {medName}",
|
||||
"phaseRedMessage": "🔴 Verpasst:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nAls verpasst markiert.",
|
||||
"buttonTaken": "Genommen",
|
||||
"buttonConfirm": "✓ Bestätigen",
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Überspringen"
|
||||
}
|
||||
}
|
||||
+165
-2
@@ -194,6 +194,8 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, welcome back.",
|
||||
"welcomeBackWithName": "{greeting} {name}, welcome back.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "days in a row",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"bloodPressureSys": "Blood Pressure (Sys)",
|
||||
@@ -600,6 +602,10 @@
|
||||
"refreshAnalysis": "Refresh analysis",
|
||||
"keyTakeaway": "Key Takeaway",
|
||||
"findingsTitle": "Findings",
|
||||
"heroFindingPositive": "Top finding",
|
||||
"heroFindingNeutral": "Top finding",
|
||||
"heroFindingAttention": "Worth attention",
|
||||
"heroFindingWarning": "Top alert",
|
||||
"correlationsTitle": "Correlations",
|
||||
"recommendationsTitle": "Recommendations",
|
||||
"dataFoundation": "Data coverage",
|
||||
@@ -794,11 +800,119 @@
|
||||
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
|
||||
"errorTargets": "Could not save your target values. Please try again.",
|
||||
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications.",
|
||||
"v2": {
|
||||
"title": "Welcome to HealthLog",
|
||||
"subtitle": "Three quick steps. You can finish anything later from Settings.",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"finish": "Finish setup",
|
||||
"skipStep": "Skip this step — you can finish setup later from Settings.",
|
||||
"step1": {
|
||||
"title": "About you",
|
||||
"description": "Used for blood-pressure targets, BMI, and the right reminders.",
|
||||
"displayNameLabel": "Display name",
|
||||
"displayNameHint": "Shown on the dashboard greeting and in reports.",
|
||||
"languageLabel": "Language",
|
||||
"languageHint": "We'll use this for the app, PDFs, and notifications."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Log your first measurement",
|
||||
"description": "Weight, blood pressure, or pulse — whatever you have right now. The dashboard comes alive with one entry.",
|
||||
"added": "Got it — you can add more anytime from the dashboard.",
|
||||
"skipHint": "No measurement at hand? Skip and log one when you're ready."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Pick one notification channel",
|
||||
"description": "We'll only send what you ask for. You can add more channels or fine-tune events later.",
|
||||
"channelTelegramTitle": "Telegram bot",
|
||||
"channelTelegramHint": "Reminders with snooze buttons inside Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native browser notifications on this device.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-source push to phone or desktop, no account.",
|
||||
"channelNoneTitle": "Skip for now",
|
||||
"channelNoneHint": "I'll set up notifications later from Settings.",
|
||||
"openSettings": "Continue in Settings"
|
||||
},
|
||||
"doneToast": "You're all set — welcome aboard."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Getting started",
|
||||
"subtitle": "Quick wins to get the most out of HealthLog.",
|
||||
"progress": "{done} of {total} done",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAll": "Hide checklist",
|
||||
"dismissTooltip": "Hide this item",
|
||||
"restoreHint": "You can restore the checklist from Settings.",
|
||||
"items": {
|
||||
"profileTitle": "Set your profile",
|
||||
"profileDescription": "Height, date of birth and gender unlock personalised targets.",
|
||||
"profileCta": "Open profile",
|
||||
"measurementTitle": "Log your first measurement",
|
||||
"measurementDescription": "One reading is enough to draw your first trend tile.",
|
||||
"measurementCta": "Add measurement",
|
||||
"medicationTitle": "Add a medication",
|
||||
"medicationDescription": "Track compliance and get reminders right when you need them.",
|
||||
"medicationCta": "Add medication",
|
||||
"withingsTitle": "Connect Withings (optional)",
|
||||
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
|
||||
"withingsCta": "Connect",
|
||||
"notificationsTitle": "Configure notifications",
|
||||
"notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.",
|
||||
"notificationsCta": "Open notifications"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "First trend after 5 readings",
|
||||
"remainingOne": "1 more reading to go.",
|
||||
"remainingMany": "{count} more readings to go."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Profile, sign-in methods, and account deletion."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Tile layout, thresholds."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer tokens for headless clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Export, import, danger zone."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Version, license, links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Moving here with the next release update."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "General",
|
||||
"categoryNotifications": "Notifications",
|
||||
@@ -1039,7 +1153,41 @@
|
||||
"moodLogSyncResult": "{count} entries synchronized",
|
||||
"moodLogSyncFailed": "Sync failed",
|
||||
"moodLogSaved": "moodLog connection saved",
|
||||
"moodLogDisconnected": "moodLog disconnected"
|
||||
"moodLogDisconnected": "moodLog disconnected",
|
||||
"about": {
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"checkUpdates": "Check for updates",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"ok": "Connected (latency {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Credentials rejected — check your token",
|
||||
"rate_limited": "Rate-limited by the upstream",
|
||||
"timeout": "Request timed out",
|
||||
"upstream_error": "Upstream returned an error",
|
||||
"connection_failed": "Connection failed",
|
||||
"not_configured": "Not configured",
|
||||
"url_not_public": "URL is not a public endpoint",
|
||||
"url_invalid": "URL is invalid",
|
||||
"redirected": "Endpoint redirects — check the URL",
|
||||
"endpoint_not_found": "Endpoint not found at the URL",
|
||||
"credentials_unreadable": "Credentials cannot be decrypted",
|
||||
"upstream_invalid_json": "Upstream sent invalid JSON",
|
||||
"vapid_not_configured": "Web Push not configured (VAPID keys missing)",
|
||||
"rate_limited_self": "Too many test requests",
|
||||
"generic": "Test failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -1629,5 +1777,20 @@
|
||||
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
|
||||
"helpHeader": "<b>Available commands:</b>",
|
||||
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken <name> — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
|
||||
},
|
||||
"medicationReminders": {
|
||||
"phaseGreenTitle": "🟢 Reminder: {medName}",
|
||||
"phaseGreenMessage": "🟢 Reminder:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nWindow closes in {minutes} min.",
|
||||
"phaseYellowTitle": "🟡 Due soon: {medName}",
|
||||
"phaseYellowMessage": "🟡 Due soon:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\n{minutes} min left.",
|
||||
"phaseOrangeTitle": "🟠 Overdue: {medName}",
|
||||
"phaseOrangeMessage": "🟠 Overdue:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nOverdue by {minutes} min.",
|
||||
"phaseRedTitle": "🔴 Missed: {medName}",
|
||||
"phaseRedMessage": "🔴 Missed:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nMarked as missed.",
|
||||
"buttonTaken": "Taken",
|
||||
"buttonConfirm": "✓ Confirm",
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Skip"
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -20,6 +20,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
@@ -55,6 +56,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",
|
||||
|
||||
Generated
+1300
-11
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
-- 0025_refresh_tokens
|
||||
-- Native-client short-lived access + rotating refresh tokens (v1.4 G4).
|
||||
-- Forward-compatible: this migration only ADDS a table. No DROP COLUMN, no
|
||||
-- type changes on existing tables. To roll back, the down migration in
|
||||
-- docs/ops/migrations/0025-down.sql DROPs the table.
|
||||
--
|
||||
-- All statements use IF NOT EXISTS so a partial-deploy retry is a no-op
|
||||
-- instead of "relation already exists". Wrapped in a single transaction
|
||||
-- so a mid-statement failure leaves no half-built table behind.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "refresh_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"device_id" TEXT,
|
||||
"access_token_hash" TEXT,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"used_at" TIMESTAMP(3),
|
||||
"replaced_by_id" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"ip_address" TEXT,
|
||||
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_device_id_idx" ON "refresh_tokens"("user_id", "device_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'refresh_tokens_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "refresh_tokens"
|
||||
ADD CONSTRAINT "refresh_tokens_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0025_user_locale_drift_fix
|
||||
-- The `users.locale` column has been on `prisma/schema.prisma` since the
|
||||
-- locale-aware reminder work in v1.3 but never landed in the migration
|
||||
-- history (it must have been applied via `prisma db push` to dev / prod
|
||||
-- but not committed as a migration). The schema drift is invisible until
|
||||
-- a fresh database is built from migrations alone — at which point the
|
||||
-- Prisma client tries to read `users.locale` and Postgres reports
|
||||
-- `column users.locale does not exist`.
|
||||
--
|
||||
-- This migration backfills the column with `IF NOT EXISTS` so it is a
|
||||
-- safe no-op against any database that was kept in sync via `db push`,
|
||||
-- and a clean add against any environment built strictly from this
|
||||
-- migrations directory (CI test containers, fresh deploys, etc.).
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "locale" TEXT;
|
||||
@@ -90,6 +90,7 @@ model User {
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
apiTokens ApiToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
moodEntries MoodEntry[]
|
||||
@@ -357,6 +358,47 @@ model ApiToken {
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
tokenHash String @unique @map("token_hash")
|
||||
/// Optional device fingerprint — links the refresh token to a specific
|
||||
/// `Device` row when the caller advertised one (X-Device-Id header). Used
|
||||
/// to revoke all tokens issued for a stolen/lost device.
|
||||
deviceId String? @map("device_id")
|
||||
/// HMAC-SHA-256 (matching `ApiToken.tokenHash` format, keyed by
|
||||
/// `API_TOKEN_HMAC_KEY`) of the access token issued in the same response.
|
||||
/// When the refresh token is rotated we revoke the matching access token
|
||||
/// via `ApiToken.tokenHash`. Null if the access token was issued
|
||||
/// out-of-band.
|
||||
accessTokenHash String? @map("access_token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
/// When this token was used (one-time-use). Set during rotation; a second
|
||||
/// use of the same token is a hard fail and triggers reuse-detection.
|
||||
usedAt DateTime? @map("used_at")
|
||||
/// Pointer to the row that replaced this one during rotation. Helps audit
|
||||
/// trail and reuse-detection ("this token was already exchanged into X").
|
||||
replacedById String? @map("replaced_by_id")
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
/// Composite index — every reuse-detection / device-scoped lookup filters
|
||||
/// on (userId, deviceId), so a single-column device_id index would never
|
||||
/// be used while costing writes. Multiple live rows per (userId, deviceId)
|
||||
/// are intentional: rotated rows linger in `revokedAt != null` state for
|
||||
/// audit and reuse-detection.
|
||||
@@index([userId, deviceId])
|
||||
@@index([expiresAt])
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// ─── Withings ──────────────────────────────────────────────
|
||||
|
||||
model WithingsConnection {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* scripts/restore-backup.ts <s3-key> [output-file]
|
||||
*
|
||||
* Downloads a single off-host backup object, decrypts it with
|
||||
* `BACKUP_ENCRYPTION_KEY`, and writes the JSON dump to disk.
|
||||
*
|
||||
* Wire format (binary):
|
||||
* magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext
|
||||
*
|
||||
* Usage:
|
||||
* BACKUP_S3_ENDPOINT=... \
|
||||
* BACKUP_S3_BUCKET=... \
|
||||
* BACKUP_S3_ACCESS_KEY=... \
|
||||
* BACKUP_S3_SECRET_KEY=... \
|
||||
* BACKUP_S3_REGION=auto \
|
||||
* BACKUP_ENCRYPTION_KEY=<hex64> \
|
||||
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
loadOffhostConfig,
|
||||
getS3Client,
|
||||
decryptBackup,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
/**
|
||||
* Schema-validate the decrypted JSON before writing it to disk. The wire
|
||||
* format from `runOffhostBackup` always carries `userId` and the four data
|
||||
* arrays — anything else means the operator may be looking at a tampered or
|
||||
* mis-targeted object. We reject rather than silently writing a body that a
|
||||
* later import script would trust.
|
||||
*/
|
||||
function validateBackupShape(payload: unknown): {
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
measurements: unknown[];
|
||||
medications: unknown[];
|
||||
intakeEvents: unknown[];
|
||||
moodEntries: unknown[];
|
||||
} {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("backup payload is not a JSON object");
|
||||
}
|
||||
const p = payload as Record<string, unknown>;
|
||||
const required = [
|
||||
"exportedAt",
|
||||
"userId",
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const;
|
||||
for (const k of required) {
|
||||
if (!(k in p)) throw new Error(`backup is missing required field: ${k}`);
|
||||
}
|
||||
if (typeof p.exportedAt !== "string")
|
||||
throw new Error("backup.exportedAt must be string");
|
||||
if (typeof p.userId !== "string" || p.userId.length === 0)
|
||||
throw new Error("backup.userId must be a non-empty string");
|
||||
for (const k of [
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const) {
|
||||
if (!Array.isArray(p[k])) throw new Error(`backup.${k} must be an array`);
|
||||
}
|
||||
return p as ReturnType<typeof validateBackupShape>;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const key = process.argv[2];
|
||||
const out = process.argv[3] ?? "./restored.json";
|
||||
// Optional positional arg: --user-id=<id> requires the dump's `userId`
|
||||
// field to match. Defends against an operator pointing the script at the
|
||||
// wrong S3 key (e.g. another tenant's dump under a similar path) and
|
||||
// silently importing the wrong user's data downstream.
|
||||
const expectedUserId = process.argv
|
||||
.find((a) => a.startsWith("--user-id="))
|
||||
?.slice("--user-id=".length);
|
||||
if (!key) {
|
||||
console.error(
|
||||
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cfg = loadOffhostConfig();
|
||||
if (!cfg) {
|
||||
throw new OffhostBackupNotConfiguredError(
|
||||
"Off-host backup is not configured. Set BACKUP_* env vars.",
|
||||
);
|
||||
}
|
||||
|
||||
const s3 = await getS3Client(cfg);
|
||||
console.log(
|
||||
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
|
||||
);
|
||||
const ciphertext = await s3.getObject(key);
|
||||
const plaintext = decryptBackup(ciphertext, cfg.encryptionKey);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Decrypted payload is not valid JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
const body = validateBackupShape(parsed);
|
||||
if (expectedUserId && body.userId !== expectedUserId) {
|
||||
throw new Error(
|
||||
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(out, plaintext, "utf8");
|
||||
console.log(
|
||||
`Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Restore failed:", err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* scripts/rotate-encryption-key.ts <new-key-id>
|
||||
*
|
||||
* Re-encrypts every encrypted column in the database with the currently
|
||||
* active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext
|
||||
* already carries the active key id prefix are skipped.
|
||||
*
|
||||
* Usage:
|
||||
* ENCRYPTION_KEYS='{"v1":"<old>","v2":"<new>"}' \
|
||||
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
|
||||
* pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("DATABASE_URL must be set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetKeyId = process.argv[2] ?? getActiveKeyId();
|
||||
if (targetKeyId !== getActiveKeyId()) {
|
||||
console.error(
|
||||
`Refusing to rotate: argv key id '${targetKeyId}' does not match the ` +
|
||||
`currently active id '${getActiveKeyId()}'. Set ENCRYPTION_ACTIVE_KEY_ID first.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
|
||||
});
|
||||
|
||||
interface RotationResult {
|
||||
table: string;
|
||||
field: string;
|
||||
scanned: number;
|
||||
rotated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
function shouldRotate(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const id = extractKeyId(value);
|
||||
// null = legacy/unversioned (rotate); else rotate if not already on active.
|
||||
return id !== getActiveKeyId();
|
||||
}
|
||||
|
||||
async function rotateField<T extends { id: string }>(
|
||||
table: string,
|
||||
field: string,
|
||||
rows: T[],
|
||||
fieldGetter: (row: T) => string | null,
|
||||
updater: (id: string, ciphertext: string) => Promise<void>,
|
||||
): Promise<RotationResult> {
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
scanned: rows.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const v = fieldGetter(row);
|
||||
if (!shouldRotate(v)) continue;
|
||||
try {
|
||||
const re = encrypt(decrypt(v as string));
|
||||
await updater(row.id, re);
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
result.errors++;
|
||||
console.error(
|
||||
`[${table}.${field}] row ${row.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`);
|
||||
const results: RotationResult[] = [];
|
||||
|
||||
// ───── User table ─────
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
moodLogWebhookSecret: true,
|
||||
telegramBotToken: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
withingsClientIdEncrypted: true,
|
||||
withingsClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userFields: Array<keyof (typeof users)[number]> = [
|
||||
"codexAccessTokenEncrypted",
|
||||
"codexRefreshTokenEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"moodLogWebhookSecret",
|
||||
"telegramBotToken",
|
||||
"moodLogUrlEncrypted",
|
||||
"moodLogApiKeyEncrypted",
|
||||
"withingsClientIdEncrypted",
|
||||
"withingsClientSecretEncrypted",
|
||||
];
|
||||
for (const field of userFields) {
|
||||
const r = await rotateField(
|
||||
"User",
|
||||
field as string,
|
||||
users,
|
||||
(u) => (u as Record<string, unknown>)[field as string] as string | null,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── WithingsConnection table (accessToken / refreshToken) ─────
|
||||
const withings = await prisma.withingsConnection.findMany({
|
||||
select: { id: true, accessToken: true, refreshToken: true },
|
||||
});
|
||||
for (const field of ["accessToken", "refreshToken"] as const) {
|
||||
const r = await rotateField(
|
||||
"WithingsConnection",
|
||||
field,
|
||||
withings,
|
||||
(w) => w[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.withingsConnection.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── AppSettings table (singleton typically) ─────
|
||||
const settings = await prisma.appSettings.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
adminAiKeyEncrypted: true,
|
||||
webPushVapidPrivateKeyEncrypted: true,
|
||||
githubIssueTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
const appFields = [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
] as const;
|
||||
for (const field of appFields) {
|
||||
const r = await rotateField(
|
||||
"AppSettings",
|
||||
field,
|
||||
settings,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.appSettings.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── NotificationChannel.config ─────
|
||||
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
|
||||
// Skipping these on rotation would leave channels permanently undecryptable
|
||||
// once the operator drops `v1` from the key map.
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
select: { id: true, config: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"NotificationChannel",
|
||||
"config",
|
||||
channels,
|
||||
(c) => c.config,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.notificationChannel.update({
|
||||
where: { id },
|
||||
data: { config: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── PushSubscription.{p256dh, auth} ─────
|
||||
// Web-push routing secrets — without these, the push endpoint is reachable
|
||||
// but the browser ignores the message (auth tag mismatch).
|
||||
const subs = await prisma.pushSubscription.findMany({
|
||||
select: { id: true, p256dh: true, auth: true },
|
||||
});
|
||||
for (const field of ["p256dh", "auth"] as const) {
|
||||
results.push(
|
||||
await rotateField(
|
||||
"PushSubscription",
|
||||
field,
|
||||
subs,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} errors=${r.errors}`,
|
||||
);
|
||||
totalRotated += r.rotated;
|
||||
totalErrors += r.errors;
|
||||
}
|
||||
console.log(`\nTOTAL rotated=${totalRotated} errors=${totalErrors}`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalErrors > 0 ? 3 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error("Rotation failed:", err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -62,6 +62,7 @@ import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { toast } from "sonner";
|
||||
import { StatusCardGrid } from "@/components/admin/status-card-grid";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -162,6 +163,8 @@ export default function AdminPage() {
|
||||
<p className="text-muted-foreground text-sm">{t("admin.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<StatusCardGrid />
|
||||
|
||||
<div className="space-y-6">
|
||||
<SystemStatusSection id="section-system-status" />
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* POST /api/admin/backup/test — admin-only smoke test for the off-host
|
||||
* backup target. Issues a 1-byte PUT + GET roundtrip and returns the
|
||||
* endpoint, bucket, region, and per-call latency. Credentials are NEVER
|
||||
* returned; only the endpoint host + bucket name are surfaced.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
runOffhostRoundtripTest,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
await requireAdmin();
|
||||
try {
|
||||
const report = await runOffhostRoundtripTest();
|
||||
annotate({
|
||||
action: { name: "admin.backup.test" },
|
||||
meta: {
|
||||
ok: report.ok,
|
||||
put_latency_ms: report.putLatencyMs,
|
||||
get_latency_ms: report.getLatencyMs,
|
||||
},
|
||||
});
|
||||
return apiSuccess(report);
|
||||
} catch (err) {
|
||||
if (err instanceof OffhostBackupNotConfiguredError) {
|
||||
return apiError(err.message, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/settings", () => {
|
||||
it("rejects with 401 when no session", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("reads the singleton row (regression: not the default-typo)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
await GET();
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.appSettings.findUnique).mock.calls[0]?.[0];
|
||||
// Must be "singleton" — the test-endpoints PR previously typo'd this as "default".
|
||||
expect(args).toEqual({ where: { id: "singleton" } });
|
||||
});
|
||||
|
||||
it("returns sane defaults when no settings row exists", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.registrationEnabled).toBe(true);
|
||||
expect(body.data.defaultLocale).toBe("de");
|
||||
expect(body.data.webPushVapidConfigured).toBe(false);
|
||||
expect(body.data.bugReportConfigured).toBe(false);
|
||||
expect(body.data.glitchtipEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("never leaks the encrypted private key fields", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The two configured-flags must be true …
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
expect(body.data.bugReportConfigured).toBe(true);
|
||||
// … but raw encrypted blobs must never reach the client.
|
||||
const serialized = JSON.stringify(body.data);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
|
||||
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/settings", () => {
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(PUT(jsonReq({}))).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 for an unknown field (strict schema)", async () => {
|
||||
const res = await PUT(jsonReq({ totallyBogus: true }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 for invalid Glitchtip DSN URL", async () => {
|
||||
const res = await PUT(jsonReq({ glitchtipDsn: "not a url" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when the body has no actionable fields", async () => {
|
||||
const res = await PUT(jsonReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("No valid fields");
|
||||
});
|
||||
|
||||
it("encrypts the VAPID private key before persisting", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(encrypt).toHaveBeenCalledWith("plain-private");
|
||||
|
||||
const upsertArgs = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsertArgs?.where).toEqual({ id: "singleton" });
|
||||
expect(upsertArgs?.update).toEqual({
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
});
|
||||
// Audit detail must NOT carry the plaintext private key.
|
||||
expect(auditLog).toHaveBeenCalled();
|
||||
const auditDetails = vi.mocked(auditLog).mock.calls[0]?.[1]
|
||||
?.details as Record<string, unknown>;
|
||||
expect(JSON.stringify(auditDetails)).not.toContain("plain-private");
|
||||
expect(auditDetails.webPushVapidPrivateKeyUpdated).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the encrypted private key when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true }));
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({ webPushVapidPrivateKeyEncrypted: null });
|
||||
});
|
||||
|
||||
it("normalises a Umami URL with no path to /script.js", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({ umamiScriptUrl: "https://analytics.example.com" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalises and persists every Zod-allowed field in a single PUT", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
moodLogGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKey: "secret",
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
bugReportRepo: "owner/repo",
|
||||
bugReportToken: "gh-token",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toMatchObject({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
apiGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
});
|
||||
expect(encrypt).toHaveBeenCalledWith("secret");
|
||||
expect(encrypt).toHaveBeenCalledWith("gh-token");
|
||||
});
|
||||
|
||||
it("clears nullable string fields when an empty string is submitted", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
await PUT(
|
||||
jsonReq({
|
||||
webPushVapidPublicKey: "",
|
||||
webPushVapidSubject: "",
|
||||
umamiScriptUrl: "",
|
||||
umamiWebsiteId: "",
|
||||
glitchtipDsn: "",
|
||||
glitchtipEnvironment: "",
|
||||
bugReportRepo: "",
|
||||
}),
|
||||
);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the encrypted bug-report token when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
await PUT(jsonReq({ clearBugReportToken: true }));
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r);
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.all (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// Single batched roundtrip — no N+1.
|
||||
const [
|
||||
userTotal,
|
||||
userAdmins,
|
||||
userNewThisWeek,
|
||||
withingsCount,
|
||||
moodLogCount,
|
||||
telegramCount,
|
||||
ntfyCount,
|
||||
webPushCount,
|
||||
appSettings,
|
||||
lastErrorEntry,
|
||||
latestBackup,
|
||||
backedUpUsers,
|
||||
last30dEvents,
|
||||
lastLoginEntry,
|
||||
lastIdempotencyCleanup,
|
||||
lastAuditLogCleanup,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { role: "ADMIN" } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
prisma.withingsConnection.count(),
|
||||
prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
prisma.pushSubscription.count(),
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt.toISOString() ?? null,
|
||||
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
measurement: { count: vi.fn() },
|
||||
medication: { count: vi.fn() },
|
||||
medicationIntakeEvent: { count: vi.fn() },
|
||||
apiToken: { count: vi.fn() },
|
||||
session: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medication.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.session.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/status (integration health summary)", () => {
|
||||
it("rejects non-admin callers with 403", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns null integration entries when no app settings exist", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, unknown> };
|
||||
};
|
||||
expect(body.data.integrations).toEqual({
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
umamiEnabled: true,
|
||||
glitchtipDsn: "https://abc@x/123",
|
||||
glitchtipEnabled: false,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, Record<string, unknown> | null> };
|
||||
};
|
||||
expect(body.data.integrations.umami).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
});
|
||||
// glitchtip is configured but the global toggle is off
|
||||
expect(body.data.integrations.glitchtip).toEqual({
|
||||
configured: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
});
|
||||
|
||||
it("does not mark webPush configured when only the public key is present", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
webPushVapidSubject: null,
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: { webPush: unknown } };
|
||||
};
|
||||
expect(body.data.integrations.webPush).toBeNull();
|
||||
});
|
||||
|
||||
it("returns aggregate counts and the worker status block", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(7);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(123);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(2);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
counts: Record<string, number>;
|
||||
worker: { running: boolean };
|
||||
database: string;
|
||||
};
|
||||
};
|
||||
expect(body.data.counts).toEqual({
|
||||
users: 7,
|
||||
measurements: 123,
|
||||
medications: 0,
|
||||
intakeEvents: 0,
|
||||
activeTokens: 2,
|
||||
activeSessions: 0,
|
||||
});
|
||||
expect(body.data.worker.running).toBe(true);
|
||||
expect(body.data.database).toBe("connected");
|
||||
|
||||
// Regression: only NON-revoked tokens are counted as "active".
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function params(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/users/[id]", () => {
|
||||
it("rejects with 401 when unauthenticated", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails Zod validation (short username)", async () => {
|
||||
const res = await PUT(jsonReq({ username: "ab" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.user.findUnique)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when role is unknown", async () => {
|
||||
const res = await PUT(jsonReq({ role: "SUPERUSER" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids demoting the last remaining admin", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/last admin/i);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows demoting an admin while another admin exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "a@b.com",
|
||||
role: "USER",
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.where).toEqual({ id: "u1" });
|
||||
expect(updateArgs?.data).toEqual({ role: "USER" });
|
||||
});
|
||||
|
||||
it("persists username + email + role and writes an audit-log entry", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
const updated = {
|
||||
id: "u1",
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
};
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(updated as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
}),
|
||||
params("u1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.username).toBe("renamed");
|
||||
expect(body.data.role).toBe("ADMIN");
|
||||
expect(body.data).not.toHaveProperty("passwordHash");
|
||||
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.user.update",
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
details: expect.objectContaining({ targetUserId: "u1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports clearing email by passing null", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ email: null }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({ email: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r, params("u1"));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const mockUserFindMany = vi.mocked(prisma.user.findMany);
|
||||
const mockRequireAdmin = vi.mocked(requireAdmin);
|
||||
|
||||
describe("GET /api/admin/users", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireAdmin.mockResolvedValue({
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
});
|
||||
mockUserFindMany.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("rejects with 401 when no session", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(new HttpError(401, "Not authenticated"));
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns users sorted by createdAt ascending with passkey counts", async () => {
|
||||
const created = new Date("2024-01-01T00:00:00Z");
|
||||
mockUserFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 2 },
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 0 },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0]).toEqual({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created.toISOString(),
|
||||
passkeyCount: 2,
|
||||
});
|
||||
expect(body.data[1].passkeyCount).toBe(0);
|
||||
expect(body.data[1].email).toBeNull();
|
||||
});
|
||||
|
||||
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
|
||||
await GET();
|
||||
expect(mockUserFindMany).toHaveBeenCalledTimes(1);
|
||||
const args = mockUserFindMany.mock.calls[0]?.[0] as {
|
||||
select: Record<string, unknown>;
|
||||
orderBy: { createdAt: "asc" | "desc" };
|
||||
};
|
||||
// Regression: the route must NOT pull password hash / token columns.
|
||||
expect(args.select).toEqual({
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
expect(args.orderBy).toEqual({ createdAt: "asc" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
|
||||
return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -29,13 +29,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (error) {
|
||||
annotate({ meta: { oauth_error: error } });
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=${encodeURIComponent(error)}`,
|
||||
`${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=missing_params`,
|
||||
`${appUrl}/settings/integrations?codex_error=missing_params`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
if (!storedState || !storedVerifier || state !== storedState) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=invalid_state`,
|
||||
`${appUrl}/settings/integrations?codex_error=invalid_state`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,9 +70,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + tokens.expires_in * 1000,
|
||||
),
|
||||
codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
codexConnectedAt: new Date(),
|
||||
codexConnectionStatus: "connected",
|
||||
insightsCachedAt: null,
|
||||
@@ -86,12 +84,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
annotate({ action: { name: "codex.oauth.callback.success" } });
|
||||
|
||||
return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`);
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings/integrations?codex_connected=true`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
annotate({ meta: { codex_token_error: message } });
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=token_exchange_failed`,
|
||||
`${appUrl}/settings/integrations?codex_error=token_exchange_failed`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,7 +24,9 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
@@ -32,7 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
@@ -41,7 +44,11 @@ vi.mock("@/lib/logging/transports", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
@@ -66,7 +73,10 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ email: "marc@example.com", password: "supersecret" }),
|
||||
body: JSON.stringify({
|
||||
email: "marc@example.com",
|
||||
password: "supersecret",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +85,9 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-1",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
@@ -100,6 +113,8 @@ describe("POST /api/auth/login — native token issuance", () => {
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
@@ -126,6 +141,8 @@ describe("POST /api/auth/login — native token issuance", () => {
|
||||
const body = (await res.json()) as { data: { token?: string } };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toMatch(/^hlk_/);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,23 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -75,22 +85,64 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// Native clients (iOS) additionally receive a long-lived Bearer token in
|
||||
// the response so they don't need to juggle the cookie jar.
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
// v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs)
|
||||
// get a 24h access token + 60d rotating refresh token. Web UAs keep the
|
||||
// legacy 90d Bearer (issued only when X-Client-Type: native is set —
|
||||
// browser flows continue to use the session cookie).
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip,
|
||||
source: "login.password",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.password", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login)
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `iOS auto-login ${new Date().toISOString()}`,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.password" },
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.password",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -29,7 +30,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
@@ -38,7 +39,11 @@ vi.mock("@/lib/logging/transports", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
@@ -67,6 +72,9 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-1",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
@@ -91,6 +99,8 @@ describe("POST /api/auth/passkey/login-verify — native token issuance", () =>
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -9,17 +14,27 @@ import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
|
||||
const rl = await checkRateLimit(
|
||||
`auth:passkey-verify:${ip}`,
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
const { data: body, error: jsonError } =
|
||||
await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const challengeId = body.challengeId as string | undefined;
|
||||
@@ -60,20 +75,59 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip ?? null,
|
||||
source: "login.passkey",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.passkey", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `iOS auto-login ${new Date().toISOString()}`,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.passkey" },
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.passkey",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* POST /api/auth/refresh — exchange a one-time-use refresh token for a new
|
||||
* access + refresh pair. Audited on every call (success + failure).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveTokenPolicy } from "@/lib/auth/native-client";
|
||||
import {
|
||||
rotateRefreshToken,
|
||||
revokeRefreshToken,
|
||||
} from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many refresh attempts. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
refreshToken?: string;
|
||||
revoke?: boolean;
|
||||
}>(request);
|
||||
if (jsonError) return jsonError;
|
||||
if (!body.refreshToken || typeof body.refreshToken !== "string") {
|
||||
return apiError("refreshToken required", 422);
|
||||
}
|
||||
|
||||
// logout-on-device path: client wants to invalidate the refresh token.
|
||||
if (body.revoke) {
|
||||
const ok = await revokeRefreshToken(body.refreshToken);
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh.revoke" },
|
||||
meta: { refresh_revoked: ok },
|
||||
});
|
||||
await auditLog("auth.token.refresh.revoke", {
|
||||
ipAddress: ip,
|
||||
details: { ok },
|
||||
});
|
||||
return apiSuccess({ revoked: ok });
|
||||
}
|
||||
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
// Refresh always uses the native policy: a refresh token only exists for
|
||||
// native callers, and we never want to upgrade it back to a 90d token.
|
||||
if (policy.refreshTokenDays === null) {
|
||||
policy.refreshTokenDays = 60;
|
||||
policy.accessTokenDays = 1;
|
||||
}
|
||||
|
||||
const result = await rotateRefreshToken({
|
||||
refreshToken: body.refreshToken,
|
||||
policy,
|
||||
deviceId: request.headers.get("x-device-id"),
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
await auditLog("auth.token.refresh.failed", {
|
||||
ipAddress: ip,
|
||||
details: { reason: result.reason },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh.failed" },
|
||||
meta: { refresh_failure: result.reason },
|
||||
});
|
||||
if (result.reason === "already_used") {
|
||||
return apiError(
|
||||
"Refresh token reuse detected — please log in again.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
return apiError("Invalid refresh token", 401);
|
||||
}
|
||||
|
||||
await auditLog("auth.token.refresh", {
|
||||
ipAddress: ip,
|
||||
details: { policy: policy.policy },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh" },
|
||||
meta: { token_policy: policy.policy },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
token: result.bundle.accessToken,
|
||||
tokenExpiresAt: result.bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: result.bundle.refreshToken,
|
||||
refreshTokenExpiresAt: result.bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -159,8 +161,8 @@ export const GET = apiHandler(async () => {
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
|
||||
[
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -185,10 +187,12 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
for (const m of recentMeasurements)
|
||||
activityDays.add(berlinDayKey(m.measuredAt));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
@@ -333,16 +337,19 @@ export const GET = apiHandler(async () => {
|
||||
).length;
|
||||
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
const t = getServerTranslator(locale).t;
|
||||
|
||||
return apiSuccess({
|
||||
greeting: {
|
||||
salutation: `Hi, ${greetingName}`,
|
||||
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: "Tage in Folge",
|
||||
label: t("dashboard.streakLabel"),
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
|
||||
@@ -17,13 +17,17 @@ import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import type { MeasurementType, GlucoseContext } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
|
||||
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -105,20 +109,24 @@ export const GET = apiHandler(async () => {
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days)
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
// (Postgres) emulated via a windowed orderBy + take-1 per type would still be
|
||||
// N round-trips, so we sort once on the wide query and pick per-type below.
|
||||
const latestEverByType = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types } },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
});
|
||||
|
||||
const latestByType: Record<string, number | null> = {};
|
||||
const avg30ByType: Record<string, number | null> = {};
|
||||
|
||||
for (const t of types) {
|
||||
// Latest value
|
||||
const latest = await prisma.measurement.findFirst({
|
||||
where: { userId, type: t },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true },
|
||||
});
|
||||
const latest = latestEverByType.find((row) => row.type === t);
|
||||
latestByType[t] = latest?.value ?? null;
|
||||
|
||||
// 30-day average
|
||||
const recentOfType = recentMeasurements.filter((m) => m.type === t);
|
||||
if (recentOfType.length > 0) {
|
||||
const sum = recentOfType.reduce((acc, m) => acc + m.value, 0);
|
||||
@@ -200,7 +208,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESC/ESH 2018",
|
||||
source: "ESH 2023",
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
|
||||
@@ -248,7 +256,7 @@ export const GET = apiHandler(async () => {
|
||||
? { category: "Moderate", color: "#f1fa8c" }
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null,
|
||||
source: "ESC/ESH 2018",
|
||||
source: "ESH 2023",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,16 +338,12 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age);
|
||||
bodyFatClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
// Body fat ranges depend on gender/age; provide simplified ranges
|
||||
let bodyFatRange: { min: number; max: number } | null = null;
|
||||
if (gender === "MALE") {
|
||||
bodyFatRange = { min: 14, max: 24 };
|
||||
} else if (gender === "FEMALE") {
|
||||
bodyFatRange = { min: 21, max: 31 };
|
||||
} else if (age != null) {
|
||||
// Average of male/female fitness+average ranges
|
||||
bodyFatRange = { min: 17.5, max: 27.5 };
|
||||
}
|
||||
// Single source of truth for the body-fat green band lives in
|
||||
// `value-bands.ts`. v1.3.3 had three different hardcoded ranges across
|
||||
// value-bands.ts / targets/route.ts / classifications.ts; the v1.4
|
||||
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
|
||||
// fitness + acceptable bands).
|
||||
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Body fat",
|
||||
@@ -585,7 +589,8 @@ export const GET = apiHandler(async () => {
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
|
||||
const overrides = (dbUser?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const overrides = (dbUser?.thresholdsJson ??
|
||||
null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
@@ -651,7 +656,10 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
|
||||
annotate({
|
||||
action: { name: "insights.targets" },
|
||||
meta: { targetCount: targets.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
targets,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
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" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/moodlog/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ entries: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
lastSyncedAt: string;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
|
||||
// Probe must hit the actual sync endpoint with Bearer auth, not a bare HEAD
|
||||
// against the configured URL.
|
||||
const fetchCall = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
const init = fetchCall[1] as RequestInit;
|
||||
expect(init.method).toBe("GET");
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer ml-key-123");
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no moodLog URL configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: null,
|
||||
moodLogApiKeyEncrypted: null,
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SSRF (private IP) with 422", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "http://127.0.0.1/moodlog",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak upstream URL or Bearer token on upstream rejection", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "sk-secret",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Authorization: Bearer sk-secret echoed from https://example.com/api/integrations/health-log/mood",
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "moodLog rejected the API key",
|
||||
};
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
code: "endpoint_not_found",
|
||||
message: "moodLog endpoint not found at the configured URL",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "moodLog rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "moodLog returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "moodLog connection failed" };
|
||||
}
|
||||
|
||||
function redact(text: string): string {
|
||||
return text
|
||||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||||
.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.moodlog.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`moodlog-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogLastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser?.moodLogUrlEncrypted || !dbUser.moodLogApiKeyEncrypted) {
|
||||
return apiError("moodLog not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
let baseUrl: string;
|
||||
let apiKey: string;
|
||||
try {
|
||||
baseUrl = decrypt(dbUser.moodLogUrlEncrypted);
|
||||
apiKey = decrypt(dbUser.moodLogApiKeyEncrypted);
|
||||
} catch {
|
||||
return apiError("moodLog credentials unreadable", 422, {
|
||||
errorCode: "credentials_unreadable",
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublicUrl(baseUrl)) {
|
||||
return apiError("moodLog URL must be a public HTTP(S) endpoint", 422, {
|
||||
errorCode: "url_not_public",
|
||||
});
|
||||
}
|
||||
|
||||
// Probe the actual sync endpoint with the actual auth header — a HEAD on the
|
||||
// bare URL would return 200 from the static landing page even if the API key
|
||||
// is invalid. Use a 1-day window to keep the upstream payload small.
|
||||
let probeUrl: URL;
|
||||
try {
|
||||
probeUrl = new URL("/api/integrations/health-log/mood", baseUrl);
|
||||
} catch {
|
||||
return apiError("moodLog URL is invalid", 422, {
|
||||
errorCode: "url_invalid",
|
||||
});
|
||||
}
|
||||
const yesterday = new Date(Date.now() - 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
probeUrl.searchParams.set("from", yesterday);
|
||||
probeUrl.searchParams.set("to", today);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(probeUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
// `manual` neutralises the redirect-follow SSRF where a public host
|
||||
// serves a 302 to 169.254.169.254 (cloud metadata) or RFC1918 ranges.
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
// 3xx in `manual` redirect mode → we never followed; treat as success
|
||||
// failure rather than chase the Location header.
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
annotate({ meta: { moodlog_test_status: res.status } });
|
||||
return apiError("moodLog redirected — check the configured URL", 502, {
|
||||
errorCode: "redirected",
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_status: res.status,
|
||||
moodlog_test_code: cat.code,
|
||||
moodlog_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
statusCode: res.status,
|
||||
latencyMs,
|
||||
lastSyncedAt: dbUser.moodLogLastSyncedAt,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_code: code,
|
||||
moodlog_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "moodLog request timed out" : "moodLog connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
user: { findMany: vi.fn() },
|
||||
moodEntry: {
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/moodlog-secret", () => ({
|
||||
readMoodLogSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ setAuth: vi.fn(), addWarning: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
const VALID_PAYLOAD = {
|
||||
event: "mood.created" as const,
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-05-08",
|
||||
time: "2026-05-08T12:00:00.000Z",
|
||||
mood: "GUT" as const,
|
||||
score: 4,
|
||||
tags: ["walk", "sun"],
|
||||
loggedVia: "WEB" as const,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue([
|
||||
{ id: "user-1", moodLogWebhookSecret: "encrypted-blob-1" },
|
||||
] as never);
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("plaintext-secret");
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.moodEntry.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/moodlog/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/webhook", () => {
|
||||
it("returns 401 when X-Webhook-Secret header is missing", async () => {
|
||||
const res = await POST(jsonRequest(VALID_PAYLOAD));
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/missing/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when no enabled user matches the supplied secret", async () => {
|
||||
// The single candidate decrypts to a different plaintext.
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("different-plaintext");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(readMoodLogSecret).toHaveBeenCalledWith("encrypted-blob-1");
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decrypts each candidate via readMoodLogSecret and matches timing-safely", async () => {
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
|
||||
{ id: "user-A", moodLogWebhookSecret: "blob-A" },
|
||||
{ id: "user-B", moodLogWebhookSecret: "blob-B" },
|
||||
] as never);
|
||||
|
||||
// First candidate decrypts to a wrong value, second decrypts to the
|
||||
// submitted secret. The route must walk the list and pick user-B.
|
||||
vi.mocked(readMoodLogSecret)
|
||||
.mockReturnValueOnce("nope")
|
||||
.mockReturnValueOnce("plaintext-secret");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(1, "blob-A");
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(2, "blob-B");
|
||||
const upsertArgs = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
// The route must use the matched user's id, not the first candidate's.
|
||||
expect(
|
||||
(upsertArgs.where as { userId_date_moodLoggedAt: { userId: string } })
|
||||
.userId_date_moodLoggedAt.userId,
|
||||
).toBe("user-B");
|
||||
});
|
||||
|
||||
it("happy path: valid secret + mood.created upserts a mood entry tagged MOODLOG", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
expect(args).toMatchObject({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
},
|
||||
create: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("idempotency: a duplicate mood.updated event hits upsert with the same composite key, so the DB no-ops", async () => {
|
||||
const updated = { ...VALID_PAYLOAD, event: "mood.updated" as const };
|
||||
|
||||
const r1 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
const r2 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(2);
|
||||
const wheres = vi.mocked(prisma.moodEntry.upsert).mock.calls.map(
|
||||
(c) =>
|
||||
(
|
||||
c[0].where as {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: string;
|
||||
date: string;
|
||||
moodLoggedAt: Date;
|
||||
};
|
||||
}
|
||||
).userId_date_moodLoggedAt,
|
||||
);
|
||||
// Both calls target the same composite key — Postgres ON CONFLICT
|
||||
// makes the second upsert a no-op write.
|
||||
expect(wheres[0]).toEqual(wheres[1]);
|
||||
});
|
||||
|
||||
it("mood.deleted removes any matching entry rather than upserting", async () => {
|
||||
const deletedPayload = {
|
||||
...VALID_PAYLOAD,
|
||||
event: "mood.deleted" as const,
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(deletedPayload, {
|
||||
"x-webhook-secret": "plaintext-secret",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("webhook.test ping short-circuits to 200 with no DB writes", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ event: "webhook.test" },
|
||||
{ "x-webhook-secret": "plaintext-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payloads that don't match the schema with 400", async () => {
|
||||
const bad = {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
// entry missing required fields
|
||||
entry: { date: "not-a-date", time: "x", mood: "BAD", score: 0 },
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(bad, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/invalid payload/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 when the global moodLog toggle is disabled", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit is exceeded — no secret lookup performed", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
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" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
getValidToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockReset();
|
||||
vi.mocked(getValidToken).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/withings/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/withings/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "plain-access",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ status: 0, body: {} }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
lastSyncedAt: string;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no Withings connection exists", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not connected/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitises 401 upstream — does not leak Bearer token or upstream URL", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "sk-secret",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Authorization: Bearer sk-secret echoed",
|
||||
url: "https://wbsapi.withings.net/measure",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/wbsapi\.withings\.net/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
type CategorisedError = { code: string; message: string };
|
||||
|
||||
function categoriseHttpStatus(status: number): CategorisedError {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "Withings connection failed" };
|
||||
}
|
||||
|
||||
// Withings returns HTTP 200 with `status` in the JSON body to signal API-level
|
||||
// errors. Reference: https://developer.withings.com/api-reference/#section/Response-status
|
||||
function categoriseApiStatus(apiStatus: number): CategorisedError {
|
||||
switch (apiStatus) {
|
||||
case 100:
|
||||
case 101:
|
||||
case 102:
|
||||
case 401:
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
case 601:
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
case 503:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings reported a temporary server error",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned an error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.withings.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`withings-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const tokenInfo = await getValidToken(user.id);
|
||||
if (!tokenInfo) {
|
||||
return apiError("Withings not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { lastSyncedAt: true },
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
action: "getmeas",
|
||||
meastypes: "1",
|
||||
lastupdate: Math.floor(Date.now() / 1000 - 60).toString(),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${tokenInfo.accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_status: res.status,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
let json: { status?: number } = {};
|
||||
try {
|
||||
json = (await res.json()) as { status?: number };
|
||||
} catch {
|
||||
annotate({
|
||||
meta: { withings_test_code: "upstream_invalid_json" },
|
||||
});
|
||||
return apiError("Withings returned an unparseable response", 502, {
|
||||
errorCode: "upstream_invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
if (json.status !== 0) {
|
||||
const apiStatus = json.status ?? -1;
|
||||
const cat = categoriseApiStatus(apiStatus);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_api_status: apiStatus,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
lastSyncedAt: connection?.lastSyncedAt ?? null,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_code: code,
|
||||
withings_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "Withings request timed out" : "Withings connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
// Strip any `Bearer <token>` substring before annotating; the catch path can
|
||||
// see DNS / TLS errors that include the request init via err.cause in some
|
||||
// runtimes, and we never want a token in Loki.
|
||||
function redact(text: string): string {
|
||||
return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring-settings", () => ({
|
||||
getGlitchtipSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring/glitchtip", () => ({
|
||||
sendGlitchtipEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ addWarning: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
|
||||
return new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
method: "POST",
|
||||
headers: { "content-type": contentType },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://pub@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "production",
|
||||
});
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValue({
|
||||
ok: true,
|
||||
method: "envelope",
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/monitoring/glitchtip", () => {
|
||||
it("returns 429 when rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(jsonReq({ message: "x" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits with skipped:true when GlitchTip is disabled", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { skipped: boolean } };
|
||||
expect(body.data.skipped).toBe(true);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when DSN is empty even if globally enabled", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { skipped: boolean } };
|
||||
expect(body.data.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 422 when JSON parse fails", async () => {
|
||||
const res = await POST(jsonReq("{not-json", "application/json"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails schema (missing message)", async () => {
|
||||
const res = await POST(jsonReq({ stack: "boom" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when message exceeds size cap (2000 chars)", async () => {
|
||||
// The Zod schema caps `message` at 2000 chars — bigger payloads must
|
||||
// be rejected before they ever leave the box.
|
||||
const huge = "x".repeat(2001);
|
||||
const res = await POST(jsonReq({ message: huge }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when stack exceeds 20 000 chars", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({ message: "boom", stack: "x".repeat(20001) }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("forwards a well-formed event with default level=error to the configured DSN", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
message: "Boom!",
|
||||
stack: "Error: Boom!\n at foo",
|
||||
type: "ReferenceError",
|
||||
url: "https://app.example.com/page",
|
||||
userAgent: "Mozilla/5.0",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { sent: boolean } };
|
||||
expect(body.data.sent).toBe(true);
|
||||
|
||||
expect(sendGlitchtipEvent).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0];
|
||||
expect(args?.dsn).toBe("https://pub@glitchtip.example.com/1");
|
||||
expect(args?.input).toMatchObject({
|
||||
environment: "production",
|
||||
message: "Boom!",
|
||||
level: "error",
|
||||
type: "ReferenceError",
|
||||
sourceTag: "healthlog-client",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the literal 'production' env when settings.glitchtipEnvironment is null", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://pub@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
await POST(jsonReq({ message: "boom" }));
|
||||
const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0];
|
||||
expect(args?.input.environment).toBe("production");
|
||||
});
|
||||
|
||||
it("returns 502 when the upstream delivery fails", async () => {
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValue({
|
||||
ok: false,
|
||||
method: "envelope",
|
||||
status: 500,
|
||||
details: "internal",
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("rate-limits per IP — uses x-forwarded-for as the bucket key", async () => {
|
||||
const r = new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "10.0.0.42",
|
||||
},
|
||||
body: JSON.stringify({ message: "boom" }),
|
||||
});
|
||||
await POST(r);
|
||||
const callArgs = vi.mocked(checkRateLimit).mock.calls[0];
|
||||
// First arg is the bucket key — must include the resolved client IP.
|
||||
expect(callArgs?.[0]).toContain("10.0.0.42");
|
||||
// 20 requests / 60 000 ms — guard the documented sliding window.
|
||||
expect(callArgs?.[1]).toBe(20);
|
||||
expect(callArgs?.[2]).toBe(60_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({
|
||||
user: { id: "u-admin" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring/glitchtip", () => ({
|
||||
sendGlitchtipEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
vi.mocked(sendGlitchtipEvent).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/monitoring/glitchtip/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/monitoring/glitchtip/test", () => {
|
||||
it("happy path returns ok with statusCode", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
method: "envelope",
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
|
||||
// CRITICAL guard against the `id: "default"` typo regression.
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "singleton" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects HTTP DSN with 422", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "http://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta: { errorCode: string };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("dsn_not_https");
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects DSN pointing at a private host (SSRF guard)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@127.0.0.1/1",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta: { errorCode: string };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("dsn_host_not_public");
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when DSN is not configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak DSN/Bearer/sk- on upstream 401", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
vi.mocked(sendGlitchtipEvent).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"401 Unauthorized: Authorization: Bearer sk-secret from https://glitch.example.com/api/1/envelope/",
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/glitch\.example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.glitchtip.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`glitchtip-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { glitchtipDsn: true },
|
||||
});
|
||||
|
||||
if (!settings?.glitchtipDsn) {
|
||||
return apiError("Glitchtip DSN not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
// DSN is admin-set, but a compromised/social-engineered admin (or a stored
|
||||
// settings record from a hostile import) could aim Glitchtip at internal
|
||||
// hosts. Validate the host is publicly reachable and uses HTTPS before we
|
||||
// hand the URL to the helper.
|
||||
let dsnHost: string;
|
||||
try {
|
||||
const parsed = new URL(settings.glitchtipDsn);
|
||||
if (parsed.protocol !== "https:") {
|
||||
return apiError("Glitchtip DSN must use HTTPS", 422, {
|
||||
errorCode: "dsn_not_https",
|
||||
});
|
||||
}
|
||||
if (!isPublicUrl(`${parsed.protocol}//${parsed.host}`)) {
|
||||
return apiError("Glitchtip DSN host is not publicly reachable", 422, {
|
||||
errorCode: "dsn_host_not_public",
|
||||
});
|
||||
}
|
||||
dsnHost = parsed.host;
|
||||
} catch {
|
||||
return apiError("Glitchtip DSN is malformed", 422, {
|
||||
errorCode: "dsn_invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const send = sendGlitchtipEvent({
|
||||
dsn: settings.glitchtipDsn,
|
||||
input: {
|
||||
environment: process.env.NODE_ENV ?? "production",
|
||||
message: "HealthLog Glitchtip self-test",
|
||||
level: "info",
|
||||
// sourceTag rides through into the event tags. Adding "noise: true" lets
|
||||
// operators silence these in their inbound Glitchtip rules so the test
|
||||
// button doesn't pollute the issue inbox.
|
||||
sourceTag: "self-test:noise",
|
||||
},
|
||||
});
|
||||
|
||||
const timeoutErr = new Error("timeout");
|
||||
timeoutErr.name = "AbortError";
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(timeoutErr), TIMEOUT_MS),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await Promise.race([send, timeout]);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!result.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
glitchtip_test_status: result.status ?? null,
|
||||
glitchtip_test_method: result.method ?? null,
|
||||
glitchtip_test_latency_ms: latencyMs,
|
||||
glitchtip_test_host: dsnHost,
|
||||
},
|
||||
});
|
||||
return apiError("Glitchtip rejected the event", 502, {
|
||||
errorCode: "upstream_error",
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
statusCode: result.status ?? 200,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isTimeout = err.name === "AbortError";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
glitchtip_test_code: code,
|
||||
glitchtip_test_host: dsnHost,
|
||||
glitchtip_test_error: err.message.slice(0, 200),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isTimeout ? "Glitchtip request timed out" : "Glitchtip connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({
|
||||
user: { id: "u-admin" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/monitoring/umami/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/monitoring/umami/test", () => {
|
||||
it("happy path returns ok with hasMarker=true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response("(function(){window.umami=function(){};})();", {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
hasMarker: boolean;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.hasMarker).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
|
||||
// CRITICAL guard: AppSettings is keyed on `id: "singleton"` everywhere
|
||||
// else in the codebase. A typo here ("default") would silently make the
|
||||
// route return 422 in prod even with valid settings.
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "singleton" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when URL not configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak Bearer/sk- or upstream URL on upstream 401", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
"Authorization: Bearer sk-secret echoed back from https://analytics.example.com/script.js",
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/analytics\.example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
function redact(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.umami.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`umami-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { umamiScriptUrl: true },
|
||||
});
|
||||
|
||||
if (!settings?.umamiScriptUrl) {
|
||||
return apiError("Umami script URL not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const url = settings.umamiScriptUrl;
|
||||
if (!isPublicUrl(url) || !url.startsWith("https://")) {
|
||||
return apiError("Umami URL must be a public HTTPS endpoint", 422, {
|
||||
errorCode: "url_not_public",
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
// `manual` neutralises a redirect-follow SSRF — even though the URL was
|
||||
// checked with isPublicUrl(), a public host can 302 to a private IP.
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
annotate({ meta: { umami_test_status: res.status } });
|
||||
return apiError("Umami URL redirects — check configuration", 502, {
|
||||
errorCode: "redirected",
|
||||
});
|
||||
}
|
||||
|
||||
// Cap response read to 256 KB so a hostile public server can't stream
|
||||
// gigabytes through us; combined with the 5 s abort, RAM use stays bounded.
|
||||
let body = "";
|
||||
if (res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
try {
|
||||
while (total < MAX_BODY_BYTES) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
body += decoder.decode(value, { stream: true });
|
||||
}
|
||||
body += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
umami_test_status: res.status,
|
||||
umami_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError("Umami connection failed", 502, {
|
||||
errorCode: "upstream_error",
|
||||
});
|
||||
}
|
||||
|
||||
// Look for tracker-shape signal rather than a literal "umami" substring —
|
||||
// a 404 page that mentions "Powered by Umami" would otherwise slip through.
|
||||
const hasMarker = /umami\.track|window\.umami|"umami"/i.test(body);
|
||||
|
||||
return apiSuccess({
|
||||
ok: hasMarker,
|
||||
statusCode: res.status,
|
||||
latencyMs,
|
||||
hasMarker,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
umami_test_code: code,
|
||||
umami_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "Umami request timed out" : "Umami connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
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" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
pushSubscription: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/vapid-config", () => ({
|
||||
getVapidConfig: vi.fn(async () => ({
|
||||
subject: "mailto:test@example.com",
|
||||
publicKey:
|
||||
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
|
||||
privateKey: "private-key-stub",
|
||||
})),
|
||||
}));
|
||||
|
||||
const sendNotification = vi.fn();
|
||||
vi.mock("web-push", () => ({
|
||||
default: {
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotification(...args),
|
||||
},
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotification(...args),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockReset();
|
||||
sendNotification.mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/notifications/web-push/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/notifications/web-push/test", () => {
|
||||
it("happy path returns ok and sends to one subscription", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
|
||||
id: "p-1",
|
||||
userId: "u-1",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
} as never);
|
||||
sendNotification.mockResolvedValueOnce({ statusCode: 201 });
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
sent: number;
|
||||
latencyMs: number;
|
||||
perEndpoint: Array<{ host: string; status: number | null }>;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.sent).toBe(1);
|
||||
expect(body.data.perEndpoint).toHaveLength(1);
|
||||
expect(body.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
|
||||
// never the full endpoint URL (the routing token is the only thing
|
||||
// protecting that subscription from anyone who learns it).
|
||||
const text = JSON.stringify(body);
|
||||
expect(text).not.toMatch(/abc123token/);
|
||||
|
||||
// Spec: only the most-recent subscription gets the test push, not all.
|
||||
expect(prisma.pushSubscription.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "u-1" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
);
|
||||
expect(sendNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no subscriptions registered", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/no push subscriptions/i);
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak Bearer/sk- or full endpoint URL when upstream rejects", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
|
||||
id: "p-1",
|
||||
userId: "u-1",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
} as never);
|
||||
sendNotification.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error(
|
||||
"401 Unauthorized: Authorization: Bearer sk-secret from https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
),
|
||||
{ statusCode: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/abc123token/);
|
||||
const parsed = JSON.parse(text) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
sent: number;
|
||||
perEndpoint: Array<{ host: string; status: number | null }>;
|
||||
}>;
|
||||
expect(parsed.data.ok).toBe(false);
|
||||
expect(parsed.data.sent).toBe(0);
|
||||
expect(parsed.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
|
||||
expect(parsed.data.perEndpoint[0].status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getVapidConfig } from "@/lib/notifications/vapid-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function hostFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
return new URL(endpoint).host;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Strip any URL from a stringified web-push library error. Push providers (FCM,
|
||||
// Mozilla autopush, etc.) embed the full subscription endpoint into 410/404
|
||||
// error messages — that endpoint token is the routing secret, so it must never
|
||||
// land in our Wide Events.
|
||||
function redactPushError(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "notifications.web-push.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`web-push-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
// Spec: only the most-recent subscription receives the test push. A user
|
||||
// with phone + tablet + desktop should not get three buzzes per click.
|
||||
const subscription = await prisma.pushSubscription.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return apiError("No push subscriptions registered", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getVapidConfig();
|
||||
if (!config) {
|
||||
return apiError("VAPID keys not configured", 422, {
|
||||
errorCode: "vapid_not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const webpush = await import("web-push");
|
||||
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
|
||||
|
||||
const pushPayload = JSON.stringify({
|
||||
title: "HealthLog",
|
||||
body: "This is a test notification from HealthLog",
|
||||
tag: "self-test",
|
||||
});
|
||||
|
||||
const host = hostFromEndpoint(subscription.endpoint);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const p256dh = decrypt(subscription.p256dh);
|
||||
const auth = decrypt(subscription.auth);
|
||||
|
||||
const result = await webpush.sendNotification(
|
||||
{ endpoint: subscription.endpoint, keys: { p256dh, auth } },
|
||||
pushPayload,
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
sent: 1,
|
||||
latencyMs,
|
||||
perEndpoint: [{ host, status: result.statusCode ?? 201 }],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number }).statusCode ?? null;
|
||||
const message = redactPushError((err as Error).message ?? "").slice(0, 200);
|
||||
annotate({
|
||||
meta: {
|
||||
web_push_test_status: status,
|
||||
web_push_test_host: host,
|
||||
web_push_test_error: message,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
ok: false,
|
||||
sent: 0,
|
||||
perEndpoint: [{ host, status }],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(50).optional(),
|
||||
heightCm: z.number().min(50).max(300).optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
gender: z.enum(["MALE", "FEMALE"]).optional(),
|
||||
@@ -46,6 +47,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data.gender = result.data.gender;
|
||||
}
|
||||
|
||||
if (result.data.displayName) {
|
||||
data.displayName = result.data.displayName;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
medication: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
telegramReminderMessage: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
telegramScheduledDeletion: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((value: string) => `decrypted:${value}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telegram", () => ({
|
||||
answerTelegramCallbackQuery: vi.fn().mockResolvedValue(undefined),
|
||||
deleteMessage: vi.fn().mockResolvedValue(undefined),
|
||||
sendTelegramMessage: vi.fn().mockResolvedValue({ messageId: 999, ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
answerTelegramCallbackQuery,
|
||||
deleteMessage,
|
||||
sendTelegramMessage,
|
||||
} from "@/lib/telegram";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
|
||||
const TG_USER = {
|
||||
id: "user-1",
|
||||
telegramBotToken: "ENC(token-blob)",
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
const MEDICATION = { id: "med-1", name: "Ramipril" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.TELEGRAM_WEBHOOK_SECRET = "sekret";
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 120,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([] as never);
|
||||
vi.mocked(answerTelegramCallbackQuery).mockResolvedValue(undefined as never);
|
||||
vi.mocked(deleteMessage).mockResolvedValue(undefined as never);
|
||||
vi.mocked(sendTelegramMessage).mockResolvedValue({
|
||||
messageId: 999,
|
||||
ok: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
else process.env.TELEGRAM_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
|
||||
function tgRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {
|
||||
"x-telegram-bot-api-secret-token": "sekret",
|
||||
},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function callbackUpdate(data: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
update_id: 100,
|
||||
callback_query: {
|
||||
id: "cb-1",
|
||||
data,
|
||||
from: { id: 7777 },
|
||||
message: {
|
||||
message_id: 555,
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Telegram webhook — secret verification", () => {
|
||||
it("returns 401 when the X-Telegram-Bot-Api-Secret-Token header is missing", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1"), {}));
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the header is present but does not match the env secret", async () => {
|
||||
const res = await POST(
|
||||
tgRequest(callbackUpdate("taken:med-1"), {
|
||||
"x-telegram-bot-api-secret-token": "WRONG",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when TELEGRAM_WEBHOOK_SECRET env var is unset (defence in depth)", async () => {
|
||||
delete process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
const res = await POST(
|
||||
tgRequest(callbackUpdate("taken:med-1"), {
|
||||
"x-telegram-bot-api-secret-token": "anything",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 200 with a valid header secret (happy path baseline)", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — rate limit", () => {
|
||||
it("returns 429 when rate-limited and never reads the secret/user", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — callback dispatch", () => {
|
||||
it("'taken:<medId>' creates a MedicationIntakeEvent + clears snoozedUntil + acks the callback", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// $transaction is invoked with [create-intake, update-medication]. Inspect
|
||||
// its argument list for both writes.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown;
|
||||
expect(Array.isArray(txOps)).toBe(true);
|
||||
|
||||
// The route calls prisma.medicationIntakeEvent.create + medication.update
|
||||
// synchronously while building the transaction array, so these mocks
|
||||
// record the arguments the way it built them.
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data).toMatchObject({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
});
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(
|
||||
/^telegram:cb:7777:555:med-1/,
|
||||
);
|
||||
|
||||
expect(prisma.medication.update).toHaveBeenCalledWith({
|
||||
where: { id: "med-1" },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalledWith(
|
||||
"decrypted:ENC(token-blob)",
|
||||
"7777",
|
||||
555,
|
||||
);
|
||||
});
|
||||
|
||||
it("'taken:<medId>' is idempotent — replaying the same callback with the same message_id does NOT create a second intake event", async () => {
|
||||
// First call: existing-row lookup misses → write happens.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null,
|
||||
);
|
||||
await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call: existing-row lookup hits → no second write.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
|
||||
id: "intake-existing",
|
||||
} as never);
|
||||
const res2 = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res2.status).toBe(200);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged
|
||||
});
|
||||
|
||||
it("'snooze:<medId>:60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("snooze:med-1:60")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medication.update).toHaveBeenCalledTimes(1);
|
||||
const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
expect(updateArgs.where).toEqual({ id: "med-1" });
|
||||
const snoozedUntil = (updateArgs.data as { snoozedUntil: Date })
|
||||
.snoozedUntil;
|
||||
expect(snoozedUntil).toBeInstanceOf(Date);
|
||||
// Allow some clock drift but verify ~60 minutes ahead.
|
||||
const deltaMs = snoozedUntil.getTime() - Date.now();
|
||||
expect(deltaMs).toBeGreaterThan(55 * 60_000);
|
||||
expect(deltaMs).toBeLessThan(65 * 60_000);
|
||||
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/Ramipril/);
|
||||
expect(ackText).toMatch(/1 hour/i);
|
||||
});
|
||||
|
||||
it("'skip:<medId>' creates an intake event with skipped=true and snoozes through end-of-day", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data).toMatchObject({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
skipped: true,
|
||||
takenAt: null,
|
||||
source: "REMINDER",
|
||||
});
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(
|
||||
/^telegram:skip:7777:555:med-1/,
|
||||
);
|
||||
|
||||
const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
const snoozedUntil = (updateArgs.data as { snoozedUntil: Date })
|
||||
.snoozedUntil;
|
||||
expect(snoozedUntil.getHours()).toBe(23);
|
||||
expect(snoozedUntil.getMinutes()).toBe(59);
|
||||
});
|
||||
|
||||
it("'ack:<medId>' answers the callback without creating an intake event", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("ack:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unknown callback action is acked with an 'unknown action' message and writes nothing", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("totally:bogus")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/unknown action/i);
|
||||
});
|
||||
|
||||
it("ignores callbacks from chats with no enrolled Telegram user (no answer, no DB writes)", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(answerTelegramCallbackQuery).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects 'taken' for an inactive medication and acks with the localised error", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/medication.*not.*found.*inactive/i);
|
||||
});
|
||||
|
||||
it("'snooze' with an empty medication id acks with errorInvalidAction (no writes)", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("snooze::60")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/invalid action/i);
|
||||
});
|
||||
|
||||
it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/medication not found/i);
|
||||
});
|
||||
|
||||
it("'add:<medId>:umid:<userMsgId>' creates an intake event and deletes both bot + user messages", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("add:med-1:umid:1234")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(
|
||||
/^telegram:add:7777:555:med-1/,
|
||||
);
|
||||
|
||||
// Bot's selection message + user's /add command message both deleted.
|
||||
const deleteCalls = vi.mocked(deleteMessage).mock.calls;
|
||||
const ids = deleteCalls.map((c) => c[2]);
|
||||
expect(ids).toContain(555);
|
||||
expect(ids).toContain(1234);
|
||||
});
|
||||
|
||||
it("'cancel_add:umid:1234' deletes both messages and acks with the cancelled message", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("cancel_add:umid:1234")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
const ids = vi.mocked(deleteMessage).mock.calls.map((c) => c[2]);
|
||||
expect(ids).toContain(555);
|
||||
expect(ids).toContain(1234);
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/cancelled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — text-message dispatch", () => {
|
||||
it("returns 200 'ok' for /help and replies with help text", async () => {
|
||||
const update = {
|
||||
update_id: 200,
|
||||
message: {
|
||||
message_id: 11,
|
||||
text: "/help",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
};
|
||||
const res = await POST(tgRequest(update));
|
||||
expect(res.status).toBe(200);
|
||||
expect(sendTelegramMessage).toHaveBeenCalledTimes(1);
|
||||
const text = vi.mocked(sendTelegramMessage).mock.calls[0][2];
|
||||
expect(text).toMatch(/available commands/i);
|
||||
});
|
||||
|
||||
it("ignores unrelated text — never calls sendTelegramMessage", async () => {
|
||||
const update = {
|
||||
update_id: 201,
|
||||
message: {
|
||||
message_id: 12,
|
||||
text: "the weather is nice today",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
};
|
||||
const res = await POST(tgRequest(update));
|
||||
expect(res.status).toBe(200);
|
||||
expect(sendTelegramMessage).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("greeting like 'hi' echoes a friendly reply", async () => {
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 202,
|
||||
message: {
|
||||
message_id: 13,
|
||||
text: "hi",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(sendTelegramMessage).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(/Hi!/);
|
||||
});
|
||||
|
||||
it("/add with no active medications replies with the 'no active' message", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 203,
|
||||
message: {
|
||||
message_id: 14,
|
||||
text: "/add",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
|
||||
/no active medication/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("/add with exactly one active medication marks it taken without prompting", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([
|
||||
{ id: "med-only", name: "Aspirin", dose: "100mg" },
|
||||
] as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 204,
|
||||
message: {
|
||||
message_id: 15,
|
||||
text: "/add",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// markMedicationTaken → findFirst on active medication
|
||||
expect(prisma.medication.findFirst).toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("/add with multiple medications sends a keyboard listing them with cancel_add", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([
|
||||
{ id: "med-a", name: "Aspirin", dose: "100mg" },
|
||||
{ id: "med-b", name: "Bisoprolol", dose: "5mg" },
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 205,
|
||||
message: {
|
||||
message_id: 16,
|
||||
text: "/add",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(sendTelegramMessage).toHaveBeenCalledTimes(1);
|
||||
const opts = vi.mocked(sendTelegramMessage).mock.calls[0][3] as {
|
||||
replyMarkup: { inline_keyboard: { callback_data: string }[][] };
|
||||
};
|
||||
const flatData = opts.replyMarkup.inline_keyboard
|
||||
.flat()
|
||||
.map((b) => b.callback_data);
|
||||
expect(flatData).toEqual(
|
||||
expect.arrayContaining([
|
||||
"add:med-a:umid:16",
|
||||
"add:med-b:umid:16",
|
||||
"cancel_add:umid:16",
|
||||
]),
|
||||
);
|
||||
// No intake yet — user still has to choose.
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
} as never);
|
||||
// Second findFirst inside markMedicationTaken
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
name: "Ramipril",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 206,
|
||||
message: {
|
||||
message_id: 17,
|
||||
text: "taken Ramipril",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(/^telegram:text:206:med-1/);
|
||||
});
|
||||
|
||||
it("'taken' (no name) with no clearly matching medication asks for a medication name", async () => {
|
||||
// findMany returns 2 → ambiguous → ask
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([
|
||||
{ id: "med-a", name: "Aspirin" },
|
||||
{ id: "med-b", name: "Bisoprolol" },
|
||||
] as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 207,
|
||||
message: {
|
||||
message_id: 18,
|
||||
text: "taken",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
|
||||
/specify a medication/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => {
|
||||
// First findFirst (lookup by name) returns null
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
|
||||
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 208,
|
||||
message: {
|
||||
message_id: 19,
|
||||
text: "taken UnknownMed",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
|
||||
/not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 200 ignored when the body has no message and no callback_query", async () => {
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 999,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ignored");
|
||||
});
|
||||
|
||||
it("returns 200 ignored when the body has no update_id", async () => {
|
||||
const res = await POST(tgRequest({ message: { text: "/help" } }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ignored");
|
||||
});
|
||||
|
||||
it("returns 200 'invalid json' when the request body is malformed JSON", async () => {
|
||||
const req = new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "sekret",
|
||||
},
|
||||
body: "{not-valid",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("invalid json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — GET verification", () => {
|
||||
it("returns 200 with a valid header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
headers: { "x-telegram-bot-api-secret-token": "sekret" },
|
||||
});
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401 without a header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/telegram/webhook");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_APP_BUILD_SHA;
|
||||
delete process.env.NEXT_PUBLIC_APP_BUILT_AT;
|
||||
});
|
||||
|
||||
interface VersionEnvelope {
|
||||
data: {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
license: string;
|
||||
repository: string;
|
||||
changelog: string;
|
||||
docs: string;
|
||||
};
|
||||
}
|
||||
|
||||
describe("GET /api/version", () => {
|
||||
it("returns the package.json version", async () => {
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/i);
|
||||
expect(body.data.license).toBe("AGPL-3.0");
|
||||
expect(body.data.repository).toContain("github.com");
|
||||
expect(body.data.changelog).toContain("CHANGELOG");
|
||||
expect(body.data.docs).toContain("docs.healthlog.dev");
|
||||
});
|
||||
|
||||
it("returns null buildSha / builtAt for a `pnpm dev` build", async () => {
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.buildSha).toBeNull();
|
||||
expect(body.data.builtAt).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces the build SHA and timestamp when the env vars are set", async () => {
|
||||
process.env.NEXT_PUBLIC_APP_BUILD_SHA = "abc1234";
|
||||
process.env.NEXT_PUBLIC_APP_BUILT_AT = "2026-05-08T12:00:00.000Z";
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.buildSha).toBe("abc1234");
|
||||
expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import packageJson from "../../../../package.json";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const revalidate = false;
|
||||
|
||||
/**
|
||||
* GET /api/version
|
||||
*
|
||||
* Public endpoint exposing the running build's version. Used by the
|
||||
* Settings → About surface, the footer of every page, and the
|
||||
* "Check for updates" button (which compares against the GHCR API).
|
||||
*
|
||||
* The build SHA and built-at timestamp come from environment variables
|
||||
* baked at image build time:
|
||||
* - `NEXT_PUBLIC_APP_BUILD_SHA` — short Git SHA, set by the
|
||||
* `docker-publish` workflow.
|
||||
* - `NEXT_PUBLIC_APP_BUILT_AT` — ISO-8601 build timestamp, same
|
||||
* workflow.
|
||||
*
|
||||
* For local `pnpm dev` neither is set; the route returns `null` and
|
||||
* the UI falls back to "development" wording.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const version = packageJson.version;
|
||||
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
|
||||
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
|
||||
|
||||
return apiSuccess({
|
||||
version,
|
||||
buildSha,
|
||||
builtAt,
|
||||
license: "AGPL-3.0",
|
||||
repository: "https://github.com/MBombeck/HealthLog",
|
||||
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
docs: "https://docs.healthlog.dev",
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=state",
|
||||
"/settings/integrations?withings=error&reason=state",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -41,7 +41,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (stateUserId !== user.id) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=user",
|
||||
"/settings/integrations?withings=error&reason=user",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -50,7 +50,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!code) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=nocode",
|
||||
"/settings/integrations?withings=error&reason=nocode",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!creds) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=nocreds",
|
||||
"/settings/integrations?withings=error&reason=nocreds",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -90,7 +90,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
// Subscribe to webhooks in background
|
||||
setupWebhook(user.id).catch((err) => getEvent()?.addWarning("Webhook setup failed: " + err));
|
||||
setupWebhook(user.id).catch((err) =>
|
||||
getEvent()?.addWarning("Webhook setup failed: " + err),
|
||||
);
|
||||
|
||||
await auditLog("withings.connect", {
|
||||
userId: user.id,
|
||||
@@ -98,7 +100,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/settings?withings=connected", process.env.NEXT_PUBLIC_APP_URL!),
|
||||
new URL(
|
||||
"/settings/integrations?withings=connected",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete("withings_state");
|
||||
return response;
|
||||
@@ -106,7 +111,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
getEvent()?.setError(err);
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=token",
|
||||
"/settings/integrations?withings=error&reason=token",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
syncUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET, HEAD } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.WITHINGS_WEBHOOK_SECRET = "test-secret";
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
search = "",
|
||||
): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/withings/webhook${search}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function formRequest(
|
||||
formBody: Record<string, string>,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const usp = new URLSearchParams(formBody);
|
||||
return new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
...headers,
|
||||
},
|
||||
body: usp.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/withings/webhook", () => {
|
||||
it("returns 401 with no secret header and no query secret", async () => {
|
||||
const res = await POST(jsonRequest({ userid: "wu-1" }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the X-Withings-Webhook-Secret header is wrong", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest({ userid: "wu-1" }, { "x-withings-webhook-secret": "WRONG" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when WITHINGS_WEBHOOK_SECRET env var is unset, regardless of incoming header", async () => {
|
||||
delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-1" },
|
||||
{ "x-withings-webhook-secret": "anything" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
"WITHINGS_WEBHOOK_SECRET not configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: valid header secret + JSON body triggers sync for the resolved user", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-42",
|
||||
withingsUserId: "wu-1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-1" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ok");
|
||||
|
||||
expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({
|
||||
where: { withingsUserId: "wu-1" },
|
||||
});
|
||||
expect(syncUserMeasurements).toHaveBeenCalledTimes(1);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
|
||||
});
|
||||
|
||||
it("legacy ?secret=… query path still authorises and emits a Wide Event warning", async () => {
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning,
|
||||
} as never);
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-99",
|
||||
withingsUserId: "wu-2",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({ userid: "wu-2" }, {}, "?secret=test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-99");
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/legacy URL query/i),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts form-encoded payloads (Withings legacy delivery format)", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-form",
|
||||
withingsUserId: "wu-form",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-form", appli: "1" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-form");
|
||||
});
|
||||
|
||||
it("returns 200 ignored when the body has no userid", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest({}, { "x-withings-webhook-secret": "test-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ignored");
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 unknown_user when no WithingsConnection matches the wire userid", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-ghost" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("unknown_user");
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 and skips sync when rate-limited", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-1" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEAD /api/withings/webhook (Withings URL verification)", () => {
|
||||
it("returns 200 with a valid header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "HEAD",
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
});
|
||||
const res = await HEAD(req);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401 with no secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "HEAD",
|
||||
});
|
||||
const res = await HEAD(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/withings/webhook", () => {
|
||||
it("returns 200 with a valid header secret and 401 without", async () => {
|
||||
const ok = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook", {
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const fail = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook"),
|
||||
);
|
||||
expect(fail.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -163,7 +163,7 @@ export default function NotificationsPage() {
|
||||
{t("notifications.noChannels")}
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">
|
||||
<Link href="/settings/notifications">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("notifications.goToSettings")}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// Stub Next.js navigation — we never actually navigate in these tests.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — the onboarding page only invalidates after
|
||||
// finish, which we don't trigger here.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
}));
|
||||
|
||||
// Stub the toaster — fires post-finish only.
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// The Logo pulls in an SVG icon set we don't need to assert on.
|
||||
vi.mock("@/components/ui/logo", () => ({
|
||||
Logo: () => null,
|
||||
}));
|
||||
|
||||
// MeasurementForm is independently tested. Stub it so step 2's render
|
||||
// doesn't drag the entire form's dependencies into this SSR test.
|
||||
vi.mock("@/components/measurements/measurement-form", () => ({
|
||||
MeasurementForm: () => null,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import OnboardingPage from "../page";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<OnboardingPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<OnboardingPage> v2", () => {
|
||||
it("renders three progress dots, not four", () => {
|
||||
const html = render();
|
||||
// The progress row holds exactly three dots — count `w-10` segments.
|
||||
const dots = html.match(/h-1\.5 w-10 rounded-full/g) ?? [];
|
||||
expect(dots.length).toBe(3);
|
||||
});
|
||||
|
||||
it("shows the v2 welcome copy and step 1 of 3 indicator", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("Welcome to HealthLog");
|
||||
expect(html).toContain("Step 1 of 3");
|
||||
expect(html).toContain("About you");
|
||||
});
|
||||
|
||||
it("renders the German title when locale is de", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Willkommen bei HealthLog");
|
||||
expect(html).toContain("Schritt 1 von 3");
|
||||
});
|
||||
|
||||
it("includes a single skip link with the descriptive label", () => {
|
||||
const html = render();
|
||||
const skipMatches =
|
||||
html.match(
|
||||
/Skip this step — you can finish setup later from Settings./g,
|
||||
) ?? [];
|
||||
// Exactly one skip control on step 1; second skip from the legacy
|
||||
// wizard is gone.
|
||||
expect(skipMatches.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not show a Back button on step 1 (first step)", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain(">Back<");
|
||||
});
|
||||
|
||||
it("provides every step-1 form field via accessible labels", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('for="ob-display-name"');
|
||||
expect(html).toContain('for="ob-language"');
|
||||
expect(html).toContain('for="ob-height"');
|
||||
expect(html).toContain('for="ob-gender"');
|
||||
expect(html).toContain('for="ob-dob"');
|
||||
});
|
||||
|
||||
it("step-1 progressbar has correct aria attributes", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('role="progressbar"');
|
||||
expect(html).toContain('aria-valuemin="1"');
|
||||
expect(html).toContain('aria-valuemax="3"');
|
||||
expect(html).toContain('aria-valuenow="1"');
|
||||
});
|
||||
});
|
||||
+333
-248
@@ -4,93 +4,128 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { Loader2, ChevronRight } from "lucide-react";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
const TOTAL_STEPS = 3;
|
||||
|
||||
type ChannelChoice = "TELEGRAM" | "WEB_PUSH" | "NTFY" | "NONE";
|
||||
|
||||
const CHANNEL_OPTIONS: ReadonlyArray<{
|
||||
id: ChannelChoice;
|
||||
Icon: typeof MessageCircle;
|
||||
titleKey: string;
|
||||
hintKey: string;
|
||||
/**
|
||||
* Hash anchor on the notifications settings page to scroll to. NONE = skip,
|
||||
* no hash. The wizard pushes the user to `/settings/notifications#<hash>`
|
||||
* after onboarding finishes — see the v1.4 settings split (PR A2-shell)
|
||||
* for the new route shape.
|
||||
*/
|
||||
hash: string | null;
|
||||
}> = [
|
||||
{
|
||||
id: "TELEGRAM",
|
||||
Icon: MessageCircle,
|
||||
titleKey: "onboarding.v2.step3.channelTelegramTitle",
|
||||
hintKey: "onboarding.v2.step3.channelTelegramHint",
|
||||
hash: "telegram",
|
||||
},
|
||||
{
|
||||
id: "WEB_PUSH",
|
||||
Icon: Bell,
|
||||
titleKey: "onboarding.v2.step3.channelWebPushTitle",
|
||||
hintKey: "onboarding.v2.step3.channelWebPushHint",
|
||||
hash: "web-push",
|
||||
},
|
||||
{
|
||||
id: "NTFY",
|
||||
Icon: Smartphone,
|
||||
titleKey: "onboarding.v2.step3.channelNtfyTitle",
|
||||
hintKey: "onboarding.v2.step3.channelNtfyHint",
|
||||
hash: "ntfy",
|
||||
},
|
||||
{
|
||||
id: "NONE",
|
||||
Icon: CheckCircle2,
|
||||
titleKey: "onboarding.v2.step3.channelNoneTitle",
|
||||
hintKey: "onboarding.v2.step3.channelNoneHint",
|
||||
hash: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const { t, locale, setLocale } = useTranslations();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Step 1: Profile
|
||||
// Step 1 — about you
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [heightCm, setHeightCm] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState("");
|
||||
|
||||
// Step 2: Medications
|
||||
const [medName, setMedName] = useState("");
|
||||
const [medDose, setMedDose] = useState("");
|
||||
const [meds, setMeds] = useState<Array<{ name: string; dose: string }>>([]);
|
||||
// Step 2 — first measurement is captured by `<MeasurementForm>`
|
||||
// directly; we just track whether the user has already submitted one
|
||||
// so the UI can switch to a "got it" state.
|
||||
const [measurementLogged, setMeasurementLogged] = useState(false);
|
||||
|
||||
// Step 3: Targets
|
||||
const [targetSys, setTargetSys] = useState("");
|
||||
const [targetDia, setTargetDia] = useState("");
|
||||
const [targetWeight, setTargetWeight] = useState("");
|
||||
// Step 3 — preferred notification channel.
|
||||
const [channelChoice, setChannelChoice] = useState<ChannelChoice>("NONE");
|
||||
|
||||
async function finishOnboarding() {
|
||||
function goBack() {
|
||||
setStep((prev) => (prev > 1 ? ((prev - 1) as 1 | 2) : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the profile + complete onboarding. Always called once at
|
||||
* the end of the wizard — independent of which steps were skipped —
|
||||
* so `onboardingCompletedAt` is set even if the user skips
|
||||
* everything except the wizard click-through.
|
||||
*/
|
||||
async function persistAndExit(opts?: { gotoSettingsHash?: string | null }) {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (displayName.trim()) body.displayName = displayName.trim();
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
|
||||
const profileRes = await fetch("/api/onboarding/complete", {
|
||||
const res = await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!profileRes.ok) {
|
||||
throw new Error(t("onboarding.errorProfile"));
|
||||
}
|
||||
|
||||
// Create medications. Server validation requires at least one schedule,
|
||||
// so we attach a sensible default — the user is informed via medScheduleHint
|
||||
// and can refine it in Medications later. Phase 3.5 of the audit replaces
|
||||
// this entire wizard with an empty-state-driven flow.
|
||||
for (const med of meds) {
|
||||
const medRes = await fetch("/api/medications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
});
|
||||
if (!medRes.ok) {
|
||||
throw new Error(t("onboarding.errorMedication", { name: med.name }));
|
||||
}
|
||||
}
|
||||
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
const targetsRes = await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
if (!targetsRes.ok) {
|
||||
throw new Error(t("onboarding.errorTargets"));
|
||||
}
|
||||
}
|
||||
if (!res.ok) throw new Error(t("onboarding.errorProfile"));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
toast.success(t("onboarding.v2.doneToast"));
|
||||
|
||||
if (opts?.gotoSettingsHash) {
|
||||
router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("onboarding.errorGeneric");
|
||||
@@ -99,79 +134,145 @@ export default function OnboardingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function addMedication() {
|
||||
if (!medName.trim()) return;
|
||||
setMeds([...meds, { name: medName.trim(), dose: medDose.trim() }]);
|
||||
setMedName("");
|
||||
setMedDose("");
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (step < TOTAL_STEPS) {
|
||||
setStep(step + 1);
|
||||
} else {
|
||||
finishOnboarding();
|
||||
setStep((step + 1) as 1 | 2 | 3);
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const target =
|
||||
channelChoice !== "NONE"
|
||||
? (CHANNEL_OPTIONS.find((c) => c.id === channelChoice)?.hash ?? null)
|
||||
: null;
|
||||
void persistAndExit({ gotoSettingsHash: target });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md space-y-6">
|
||||
<div className="mx-auto w-full max-w-md space-y-6 py-6">
|
||||
<div className="text-center">
|
||||
<Logo className="text-primary mx-auto mb-4" size={48} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("onboarding.welcome")}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("onboarding.v2.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t("onboarding.setupDescription")}
|
||||
{t("onboarding.v2.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex justify-center gap-2">
|
||||
{/* Progress indicator — labelled for screen readers */}
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={TOTAL_STEPS}
|
||||
aria-valuenow={step}
|
||||
aria-label={t("onboarding.v2.stepOf", {
|
||||
current: step,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1.5 w-8 rounded-full transition-colors",
|
||||
"h-1.5 w-10 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
|
||||
i + 1 <= step ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground -mt-3 text-center text-xs">
|
||||
{t("onboarding.v2.stepOf", { current: step, total: TOTAL_STEPS })}
|
||||
</p>
|
||||
|
||||
{/* Step 1: Profile */}
|
||||
{/* ───── Step 1 — about you ───── */}
|
||||
{step === 1 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-height">{t("settings.height")}</Label>
|
||||
<Input
|
||||
id="ob-height"
|
||||
type="number"
|
||||
value={heightCm}
|
||||
onChange={(e) => setHeightCm(e.target.value)}
|
||||
placeholder="175"
|
||||
min={50}
|
||||
max={300}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
|
||||
<select
|
||||
id="ob-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-[3px] focus-visible:outline-none"
|
||||
<section
|
||||
aria-labelledby="ob-step1-title"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="ob-step1-title"
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
>
|
||||
<option value="">{t("settings.genderNone")}</option>
|
||||
<option value="MALE">{t("settings.genderMale")}</option>
|
||||
<option value="FEMALE">{t("settings.genderFemale")}</option>
|
||||
</select>
|
||||
{t("onboarding.v2.step1.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.v2.step1.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-display-name">
|
||||
{t("onboarding.v2.step1.displayNameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="nickname"
|
||||
placeholder={t("settings.username")}
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.genderHint")}
|
||||
{t("onboarding.v2.step1.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-language">
|
||||
{t("onboarding.v2.step1.languageLabel")}
|
||||
</Label>
|
||||
<select
|
||||
id="ob-language"
|
||||
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-[3px] 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("onboarding.v2.step1.languageHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-height">{t("settings.height")}</Label>
|
||||
<Input
|
||||
id="ob-height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={heightCm}
|
||||
onChange={(e) => setHeightCm(e.target.value)}
|
||||
placeholder="175"
|
||||
min={50}
|
||||
max={300}
|
||||
step={0.1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
|
||||
<select
|
||||
id="ob-gender"
|
||||
value={gender}
|
||||
onChange={(e) => setGender(e.target.value)}
|
||||
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-[3px] 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-dob">{t("settings.dateOfBirth")}</Label>
|
||||
<Input
|
||||
@@ -180,181 +281,165 @@ export default function OnboardingPage() {
|
||||
value={dateOfBirth}
|
||||
onChange={(e) => setDateOfBirth(e.target.value)}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
autoComplete="bday"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dateOfBirthHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 2: Medications */}
|
||||
{/* ───── Step 2 — first measurement ───── */}
|
||||
{step === 2 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.medicationsTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medicationsDescription")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={medName}
|
||||
onChange={(e) => setMedName(e.target.value)}
|
||||
placeholder={t("onboarding.medNamePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={medDose}
|
||||
onChange={(e) => setMedDose(e.target.value)}
|
||||
placeholder={t("onboarding.medDosePlaceholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addMedication}
|
||||
<section
|
||||
aria-labelledby="ob-step2-title"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="ob-step2-title"
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{meds.length > 0 && (
|
||||
<>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5"
|
||||
>
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medScheduleHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Notifications teaser */}
|
||||
{step === 3 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.notificationsTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.notificationsDescription")}
|
||||
</p>
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
|
||||
<p>
|
||||
📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}
|
||||
{t("onboarding.v2.step2.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.v2.step2.description")}
|
||||
</p>
|
||||
<p>
|
||||
🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.notificationsLater")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Step 4: Targets */}
|
||||
{step === 4 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.targetsTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.targetsDescription")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ob-sys">{t("onboarding.targetSys")}</Label>
|
||||
<Input
|
||||
id="ob-sys"
|
||||
type="number"
|
||||
value={targetSys}
|
||||
onChange={(e) => setTargetSys(e.target.value)}
|
||||
placeholder="130"
|
||||
/>
|
||||
{measurementLogged ? (
|
||||
<div className="border-primary/40 bg-primary/5 text-foreground flex items-start gap-3 rounded-lg border p-4 text-sm">
|
||||
<CheckCircle2 className="text-primary mt-0.5 size-5 shrink-0" />
|
||||
<p>{t("onboarding.v2.step2.added")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ob-dia">{t("onboarding.targetDia")}</Label>
|
||||
<Input
|
||||
id="ob-dia"
|
||||
type="number"
|
||||
value={targetDia}
|
||||
onChange={(e) => setTargetDia(e.target.value)}
|
||||
placeholder="85"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ob-weight">{t("onboarding.targetWeight")}</Label>
|
||||
<Input
|
||||
id="ob-weight"
|
||||
type="number"
|
||||
value={targetWeight}
|
||||
onChange={(e) => setTargetWeight(e.target.value)}
|
||||
placeholder="75"
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={nextStep} className="flex-1" disabled={saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{step < TOTAL_STEPS ? (
|
||||
<>
|
||||
{t("common.next")}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
t("onboarding.completeSetup")
|
||||
<MeasurementForm onSuccess={() => setMeasurementLogged(true)} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.v2.step2.skipHint")}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ───── Step 3 — pick a channel ───── */}
|
||||
{step === 3 && (
|
||||
<section
|
||||
aria-labelledby="ob-step3-title"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="ob-step3-title"
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
>
|
||||
{t("onboarding.v2.step3.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.v2.step3.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="sr-only">
|
||||
{t("onboarding.v2.step3.title")}
|
||||
</legend>
|
||||
{CHANNEL_OPTIONS.map((option) => {
|
||||
const checked = channelChoice === option.id;
|
||||
const Icon = option.Icon;
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors duration-150 ease-out motion-reduce:transition-none",
|
||||
checked && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="ob-channel"
|
||||
value={option.id}
|
||||
checked={checked}
|
||||
onChange={() => setChannelChoice(option.id)}
|
||||
className="text-primary focus-visible:ring-ring mt-1 size-4 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">
|
||||
{t(option.titleKey)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t(option.hintKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ───── Navigation row ───── */}
|
||||
<div className="flex items-center gap-3">
|
||||
{step > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goBack}
|
||||
disabled={saving}
|
||||
>
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("onboarding.v2.back")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
|
||||
{step < TOTAL_STEPS ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
className="ml-auto"
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-foreground text-sm underline underline-offset-4 transition-colors"
|
||||
>
|
||||
{t("onboarding.skip")}
|
||||
</button>
|
||||
{t("onboarding.v2.continue")}
|
||||
<ArrowRight className="ml-1 size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Skip targets and finish
|
||||
finishOnboarding();
|
||||
}}
|
||||
onClick={finish}
|
||||
className="ml-auto"
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-foreground text-sm underline underline-offset-4 transition-colors"
|
||||
>
|
||||
{t("onboarding.skip")}
|
||||
</button>
|
||||
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
{channelChoice === "NONE"
|
||||
? t("onboarding.v2.finish")
|
||||
: t("onboarding.v2.step3.openSettings")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single Skip per step. Step 3's "NONE" choice already covers
|
||||
skipping notifications, so we hide the underline on step 3. */}
|
||||
{step < TOTAL_STEPS ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-foreground mx-auto block text-sm underline-offset-4 transition-colors duration-150 ease-out hover:underline motion-reduce:transition-none"
|
||||
>
|
||||
{t("onboarding.v2.skipStep")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+149
-29
@@ -40,6 +40,8 @@ import {
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { TrendCard } from "@/components/charts/trend-card";
|
||||
import { TrendHint } from "@/components/charts/trend-hint";
|
||||
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
@@ -155,7 +157,9 @@ export default function DashboardPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [quickEntryDialog, setQuickEntryDialog] = useState<"measurement" | "mood" | null>(null);
|
||||
const [quickEntryDialog, setQuickEntryDialog] = useState<
|
||||
"measurement" | "mood" | null
|
||||
>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.analytics(),
|
||||
@@ -185,7 +189,10 @@ export default function DashboardPage() {
|
||||
const res = await fetch("/api/mood/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as { entries: Array<{ date: string; score: number; samples: number }>; summary: DataSummary };
|
||||
return json.data as {
|
||||
entries: Array<{ date: string; score: number; samples: number }>;
|
||||
summary: DataSummary;
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
@@ -230,11 +237,17 @@ export default function DashboardPage() {
|
||||
const glucoseWidgetVisible = isWidgetVisible("glucose");
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const glucoseByContext = data?.glucoseByContext ?? {};
|
||||
const glucoseContexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||||
const glucoseContexts = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
] as const;
|
||||
const glucoseSummariesPresent = glucoseContexts.filter(
|
||||
(ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0,
|
||||
);
|
||||
const showGlucoseCards = glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
|
||||
const showGlucoseCards =
|
||||
glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
|
||||
const glucoseLabelKey: Record<string, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
@@ -356,7 +369,9 @@ export default function DashboardPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setQuickEntryDialog("measurement")}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
{t("measurements.addMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
@@ -368,6 +383,12 @@ export default function DashboardPage() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* v1.4: Getting-started checklist for brand-new users.
|
||||
* Self-gates visibility on (onboardingCompletedAt == null
|
||||
* || measurementCount < 5) and disappears once dismissed
|
||||
* or fully complete. See B2 in the v1.4 discovery summary. */}
|
||||
<GettingStartedChecklist />
|
||||
|
||||
{/* Quick Entry Dialogs */}
|
||||
<Dialog
|
||||
open={quickEntryDialog === "measurement"}
|
||||
@@ -414,10 +435,24 @@ export default function DashboardPage() {
|
||||
unit="kg"
|
||||
avg7={w?.avg7 ?? null}
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, {
|
||||
range: weightRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, {
|
||||
range: weightRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"kg",
|
||||
{ range: weightRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"kg",
|
||||
{ range: weightRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
@@ -436,10 +471,24 @@ export default function DashboardPage() {
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, { range: bpSysRange })}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, { range: bpSysRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -449,10 +498,24 @@ export default function DashboardPage() {
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, { range: bpDiaRange })}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, { range: bpDiaRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -472,10 +535,24 @@ export default function DashboardPage() {
|
||||
unit="bpm"
|
||||
avg7={p?.avg7 ?? null}
|
||||
avg30={p?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, { range: pulseDisplayRange })}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, { range: pulseDisplayRange })}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"bpm",
|
||||
{ range: pulseDisplayRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"bpm",
|
||||
{ range: pulseDisplayRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
@@ -610,12 +687,23 @@ export default function DashboardPage() {
|
||||
|
||||
trendCards.sort((a, b) => a.order - b.order);
|
||||
|
||||
type ChartEntry = { id: string; order: number; node: React.ReactNode };
|
||||
type ChartEntry = {
|
||||
id: string;
|
||||
order: number;
|
||||
node: React.ReactNode;
|
||||
/**
|
||||
* Total raw readings for this metric. <5 surfaces a contextual
|
||||
* "First trend after 5 readings" hint underneath the chart;
|
||||
* undefined disables the hint (e.g. medications card).
|
||||
*/
|
||||
count?: number;
|
||||
};
|
||||
const charts: ChartEntry[] = [];
|
||||
if (showWeightCard) {
|
||||
charts.push({
|
||||
id: "weight-chart",
|
||||
order: widgetOrder("weight"),
|
||||
count: w?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="weight-chart"
|
||||
@@ -655,6 +743,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "bp-chart",
|
||||
order: widgetOrder("bp"),
|
||||
count: Math.max(sys?.count ?? 0, dia?.count ?? 0),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bp-chart"
|
||||
@@ -672,6 +761,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "pulse-chart",
|
||||
order: widgetOrder("pulse"),
|
||||
count: p?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="pulse-chart"
|
||||
@@ -688,6 +778,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "bodyFat-chart",
|
||||
order: widgetOrder("bodyFat"),
|
||||
count: bf?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bodyFat-chart"
|
||||
@@ -704,6 +795,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "mood-chart",
|
||||
order: widgetOrder("mood"),
|
||||
count: moodSummary?.count ?? 0,
|
||||
node: <MoodChart key="mood-chart" />,
|
||||
});
|
||||
}
|
||||
@@ -711,6 +803,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "sleep-chart",
|
||||
order: widgetOrder("sleep"),
|
||||
count: sleepSummary?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="sleep-chart"
|
||||
@@ -726,6 +819,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "steps-chart",
|
||||
order: widgetOrder("steps"),
|
||||
count: stepsSummary?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="steps-chart"
|
||||
@@ -741,10 +835,7 @@ export default function DashboardPage() {
|
||||
id: "medications",
|
||||
order: widgetOrder("medications"),
|
||||
node: (
|
||||
<div
|
||||
key="medications"
|
||||
className="bg-card rounded-lg border p-4"
|
||||
>
|
||||
<div key="medications" className="bg-card rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Pill className="h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">
|
||||
@@ -763,10 +854,39 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{trendCards.map((entry) => entry.node)}
|
||||
{/* v1.4: dashboard tiles are *always* a single row.
|
||||
* Marc-explicit (memory feedback_dashboard_one_row.md): a 2-row
|
||||
* tile strip breaks the visual hierarchy and reads like an
|
||||
* Excel grid. Total width caps at the parent container —
|
||||
* exactly the chart-width below. When the active tile count
|
||||
* exceeds what fits the viewport, the strip horizontal-scrolls
|
||||
* instead of wrapping; the user trims the set in
|
||||
* Settings → Dashboard (`/settings/dashboard`).
|
||||
* Each tile keeps a `min-w-[10rem]` so a single tile still
|
||||
* looks substantial on a wide screen, and `snap-x snap-mandatory`
|
||||
* makes the scroll feel deliberate rather than arbitrary on
|
||||
* touch.
|
||||
*/}
|
||||
<div
|
||||
className="-mx-2 flex snap-x snap-mandatory gap-3 overflow-x-auto px-2 pb-2"
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="shrink-0 grow basis-[10rem] snap-start"
|
||||
>
|
||||
{entry.node}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{charts.map((entry) => entry.node)}
|
||||
{charts.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
{entry.node}
|
||||
{entry.count != null ? <TrendHint count={entry.count} /> : null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AdvancedSection } from "@/components/settings/advanced-section";
|
||||
import { AiSection } from "@/components/settings/ai-section";
|
||||
import { ApiSection } from "@/components/settings/api-section";
|
||||
import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
|
||||
/**
|
||||
* Dynamic settings section route. Each of the eight `SETTINGS_SECTION_SLUGS`
|
||||
* is pre-rendered at build via `generateStaticParams()` so the URLs are
|
||||
* statically known to Next.js, while the `dynamicParams = false` flag below
|
||||
* tells the router to 404 (instead of attempting on-demand rendering) for any
|
||||
* slug not in the list — which is exactly what `notFound()` would do at
|
||||
* request time, just earlier and without rendering.
|
||||
*/
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return SETTINGS_SECTION_SLUGS.map((section) => ({ section }));
|
||||
}
|
||||
|
||||
const SECTION_COMPONENTS: Record<
|
||||
SettingsSectionSlug,
|
||||
() => JSX.Element | null
|
||||
> = {
|
||||
account: AccountSection,
|
||||
about: AboutSection,
|
||||
ai: AiSection,
|
||||
integrations: IntegrationsSection,
|
||||
notifications: NotificationsSection,
|
||||
dashboard: DashboardSection,
|
||||
api: ApiSection,
|
||||
advanced: AdvancedSection,
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
// Next.js 16 made route `params` an async Promise. We `await` it before use.
|
||||
params: Promise<{ section: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
const { section } = await params;
|
||||
|
||||
// Defence-in-depth — `dynamicParams = false` already 404s unknown slugs at
|
||||
// routing time, but we re-check here so a hand-rolled override of the route
|
||||
// config can never silently fall through to a typo'd slug.
|
||||
if (!isSettingsSectionSlug(section)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const SectionComponent = SECTION_COMPONENTS[section];
|
||||
// Defensive fallback: in theory unreachable since `isSettingsSectionSlug`
|
||||
// guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS`
|
||||
// without a wired component would otherwise crash silently — placeholder
|
||||
// surfaces the gap visually instead.
|
||||
const body = SectionComponent ? (
|
||||
<SectionComponent />
|
||||
) : (
|
||||
<SectionPlaceholder slug={section} />
|
||||
);
|
||||
|
||||
return <SettingsShell active={section}>{body}</SettingsShell>;
|
||||
}
|
||||
+18
-3148
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { StatusBadge, buildCards } from "../status-card-grid";
|
||||
import type { StatusOverview } from "@/app/api/admin/status-overview/route";
|
||||
|
||||
const mockOverview: StatusOverview = {
|
||||
users: { severity: "good", total: 3, admins: 1, newThisWeek: 1 },
|
||||
integrations: {
|
||||
severity: "good",
|
||||
withings: 1,
|
||||
moodLog: 0,
|
||||
telegram: 1,
|
||||
ntfy: 0,
|
||||
webPush: 2,
|
||||
},
|
||||
monitoring: {
|
||||
severity: "caution",
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: false,
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: null,
|
||||
},
|
||||
backups: {
|
||||
severity: "alert",
|
||||
lastBackupAt: null,
|
||||
backedUpUsers: 0,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: "good",
|
||||
workerRunning: true,
|
||||
workerUptimeSeconds: 3600,
|
||||
lastIdempotencyCleanup: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastAuditLogCleanup: null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: 42,
|
||||
lastLoginAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
describe("<StatusBadge>", () => {
|
||||
it("renders a colored dot plus a text label (never color-only)", () => {
|
||||
const html = renderToStaticMarkup(<StatusBadge severity="good" />);
|
||||
// dot must be aria-hidden so SR users don't hear it
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
// label must be present so color-blind users can read state
|
||||
expect(html).toContain("Healthy");
|
||||
// wrapper carries the announcement
|
||||
expect(html).toContain('aria-label="Status: Healthy"');
|
||||
});
|
||||
|
||||
it("maps severity to the §2.6 dracula color tokens", () => {
|
||||
const cases: Array<
|
||||
[Parameters<typeof StatusBadge>[0]["severity"], string]
|
||||
> = [
|
||||
["good", "var(--dracula-green)"],
|
||||
["info", "var(--dracula-cyan)"],
|
||||
["caution", "var(--dracula-orange)"],
|
||||
["alert", "var(--dracula-red)"],
|
||||
["pending", "var(--muted-foreground)"],
|
||||
];
|
||||
for (const [severity, token] of cases) {
|
||||
const html = renderToStaticMarkup(<StatusBadge severity={severity} />);
|
||||
expect(html).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects an explicit label override", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusBadge severity="alert" label="Disk full" />,
|
||||
);
|
||||
expect(html).toContain("Disk full");
|
||||
expect(html).toContain('aria-label="Status: Disk full"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCards()", () => {
|
||||
it("returns exactly 6 cards in the §3.4 admin order", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
expect(cards).toHaveLength(6);
|
||||
expect(cards.map((c) => c.title)).toEqual([
|
||||
"Users",
|
||||
"Integrations",
|
||||
"Monitoring",
|
||||
"Backups",
|
||||
"Maintenance",
|
||||
"Audit log",
|
||||
]);
|
||||
});
|
||||
|
||||
it("propagates severity from the aggregator response", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
expect(cards[0].severity).toBe("good"); // users
|
||||
expect(cards[2].severity).toBe("caution"); // monitoring
|
||||
expect(cards[3].severity).toBe("alert"); // backups (no backup yet)
|
||||
expect(cards[5].severity).toBe("info"); // audit log
|
||||
});
|
||||
|
||||
it("links each card to a manageable destination", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.href.startsWith("/admin")).toBe(true);
|
||||
expect(card.cta.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes 3 metric tuples per card", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.metrics).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders worker status as plain text, not color alone", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
const maintenance = cards.find((c) => c.title === "Maintenance");
|
||||
expect(maintenance?.metrics[0].value).toBe("Running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Users,
|
||||
Plug,
|
||||
Activity,
|
||||
Database,
|
||||
Wrench,
|
||||
ScrollText,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
StatusOverview,
|
||||
StatusSeverity,
|
||||
} from "@/app/api/admin/status-overview/route";
|
||||
|
||||
/**
|
||||
* §2.6 status taxonomy. Renders a colored dot AND a label so we never
|
||||
* lean on color alone (WCAG 1.4.1).
|
||||
*/
|
||||
const SEVERITY_LABEL: Record<StatusSeverity, string> = {
|
||||
good: "Healthy",
|
||||
info: "Up to date",
|
||||
caution: "Attention",
|
||||
alert: "Action required",
|
||||
pending: "Loading",
|
||||
};
|
||||
|
||||
const SEVERITY_COLOR: Record<StatusSeverity, string> = {
|
||||
good: "var(--dracula-green)",
|
||||
info: "var(--dracula-cyan)",
|
||||
caution: "var(--dracula-orange)",
|
||||
alert: "var(--dracula-red)",
|
||||
pending: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
severity,
|
||||
label,
|
||||
}: {
|
||||
severity: StatusSeverity;
|
||||
label?: string;
|
||||
}) {
|
||||
const text = label ?? SEVERITY_LABEL[severity];
|
||||
return (
|
||||
<span
|
||||
className="bg-muted/40 inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
aria-label={`Status: ${text}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: SEVERITY_COLOR[severity] }}
|
||||
/>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
severity: StatusSeverity;
|
||||
metrics: Array<{ label: string; value: string | number }>;
|
||||
href: string;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
title,
|
||||
icon,
|
||||
severity,
|
||||
metrics,
|
||||
href,
|
||||
cta,
|
||||
}: StatusCardProps) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<StatusBadge severity={severity} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-3 gap-2">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="flex flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{m.label}</dt>
|
||||
<dd className="font-mono text-sm tabular-nums">{m.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Link
|
||||
href={href}
|
||||
className="text-primary inline-flex items-center gap-1 self-start text-sm hover:underline"
|
||||
>
|
||||
{cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const min = Math.round(ms / 60_000);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) return `${hr}h ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
function fmtUptime(seconds: number | null): string {
|
||||
if (seconds === null) return "—";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86_400)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the 6 admin status cards from the aggregator response. Pure;
|
||||
* no data fetching here — easy to unit test.
|
||||
*/
|
||||
export function buildCards(data: StatusOverview): StatusCardProps[] {
|
||||
return [
|
||||
{
|
||||
title: "Users",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
severity: data.users.severity,
|
||||
metrics: [
|
||||
{ label: "Total", value: data.users.total },
|
||||
{ label: "Admins", value: data.users.admins },
|
||||
{ label: "New 7d", value: data.users.newThisWeek },
|
||||
],
|
||||
href: "/admin/users",
|
||||
cta: "Manage users",
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
icon: <Plug className="h-4 w-4" />,
|
||||
severity: data.integrations.severity,
|
||||
metrics: [
|
||||
{ label: "Withings", value: data.integrations.withings },
|
||||
{ label: "moodLog", value: data.integrations.moodLog },
|
||||
{ label: "Push", value: data.integrations.webPush },
|
||||
],
|
||||
href: "/admin#integrations",
|
||||
cta: "Configure",
|
||||
},
|
||||
{
|
||||
title: "Monitoring",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
severity: data.monitoring.severity,
|
||||
metrics: [
|
||||
{
|
||||
label: "GlitchTip",
|
||||
value: data.monitoring.glitchtipEnabled ? "On" : "Off",
|
||||
},
|
||||
{
|
||||
label: "Umami",
|
||||
value: data.monitoring.umamiEnabled ? "On" : "Off",
|
||||
},
|
||||
{ label: "Last err", value: fmtRelative(data.monitoring.lastErrorAt) },
|
||||
],
|
||||
href: "/admin#monitoring",
|
||||
cta: "View monitoring",
|
||||
},
|
||||
{
|
||||
title: "Backups",
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
severity: data.backups.severity,
|
||||
metrics: [
|
||||
{ label: "Last", value: fmtRelative(data.backups.lastBackupAt) },
|
||||
{ label: "Users", value: data.backups.backedUpUsers },
|
||||
{ label: "Retain", value: `${data.backups.retentionDays}d` },
|
||||
],
|
||||
href: "/admin#backups",
|
||||
cta: "View backups",
|
||||
},
|
||||
{
|
||||
title: "Maintenance",
|
||||
icon: <Wrench className="h-4 w-4" />,
|
||||
severity: data.maintenance.severity,
|
||||
metrics: [
|
||||
{
|
||||
label: "Worker",
|
||||
value: data.maintenance.workerRunning ? "Running" : "Stopped",
|
||||
},
|
||||
{
|
||||
label: "Uptime",
|
||||
value: fmtUptime(data.maintenance.workerUptimeSeconds),
|
||||
},
|
||||
{
|
||||
label: "Cleanup",
|
||||
value: fmtRelative(data.maintenance.lastIdempotencyCleanup),
|
||||
},
|
||||
],
|
||||
href: "/admin#maintenance",
|
||||
cta: "View jobs",
|
||||
},
|
||||
{
|
||||
title: "Audit log",
|
||||
icon: <ScrollText className="h-4 w-4" />,
|
||||
severity: data.auditLog.severity,
|
||||
metrics: [
|
||||
{ label: "Events 30d", value: data.auditLog.eventsLast30d },
|
||||
{ label: "Last login", value: fmtRelative(data.auditLog.lastLoginAt) },
|
||||
{ label: "—", value: "" },
|
||||
],
|
||||
href: "/admin/audit-log",
|
||||
cta: "Open viewer",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function StatusCardGrid() {
|
||||
const { data, isLoading } = useQuery<StatusOverview>({
|
||||
queryKey: ["admin", "status-overview"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/status-overview");
|
||||
if (!res.ok) throw new Error("Failed to load admin status overview");
|
||||
const json = (await res.json()) as { data: StatusOverview };
|
||||
return json.data;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = buildCards(data);
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map((c) => (
|
||||
<StatusCard key={c.title} {...c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { TrendHint } from "../trend-hint";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TrendHint>", () => {
|
||||
it("renders nothing for 0 readings", () => {
|
||||
expect(render(<TrendHint count={0} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing once 5+ readings exist", () => {
|
||||
expect(render(<TrendHint count={5} />)).toBe("");
|
||||
expect(render(<TrendHint count={42} />)).toBe("");
|
||||
});
|
||||
|
||||
it("shows a singular wording when only 1 reading missing", () => {
|
||||
const html = render(<TrendHint count={4} />);
|
||||
expect(html).toContain("First trend after 5 readings");
|
||||
expect(html).toContain("1 more reading to go.");
|
||||
});
|
||||
|
||||
it("shows a plural wording for >1 missing readings", () => {
|
||||
const html = render(<TrendHint count={1} />);
|
||||
expect(html).toContain("4 more readings to go.");
|
||||
});
|
||||
|
||||
it("translates to German when locale is de", () => {
|
||||
const html = render(<TrendHint count={3} />, "de");
|
||||
expect(html).toContain("Erster Trend nach 5 Messungen");
|
||||
expect(html).toContain("Noch 2 Messungen bis zum Trend.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { trendHintFor } from "@/lib/onboarding/checklist";
|
||||
|
||||
interface TrendHintProps {
|
||||
/** Total readings logged for this metric (raw count, not days). */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny banner shown below or alongside a chart for users still in the
|
||||
* "first week" of HealthLog: ≥1 but <5 readings of this metric. We
|
||||
* mount the existing `<EmptyState variant="plain">` primitive so the
|
||||
* shape matches every other empty-state in the app and inherits its
|
||||
* polite-live-region semantics. Renders nothing once the user crosses
|
||||
* 5 readings — no flicker.
|
||||
*/
|
||||
export function TrendHint({ count }: TrendHintProps) {
|
||||
const { t } = useTranslations();
|
||||
const result = trendHintFor(count);
|
||||
if (result.kind === "hidden") return null;
|
||||
|
||||
const description =
|
||||
result.remaining === 1
|
||||
? t("trendHints.remainingOne")
|
||||
: t("trendHints.remainingMany", { count: result.remaining });
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
title={t("trendHints.title")}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// HealthChart pulls in TanStack Query + recharts. We don't care about its
|
||||
// internal rendering — we only need to know *that it was rendered* and
|
||||
// with which `types[]` (which is the chart's metric handle, since the
|
||||
// real component takes `types: string[]` not `metric: string`).
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
|
||||
<div data-testid="health-chart" data-types={types.join(",")}>
|
||||
chart:{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — InsightAdvisorCard itself doesn't query, but
|
||||
// nested clients (and the HealthChart mock above replaces the real one)
|
||||
// pull from this. Safe no-op.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
|
||||
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "All good",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [
|
||||
{
|
||||
label: "Heart rate stable",
|
||||
value: "72 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep going",
|
||||
recommendations: [],
|
||||
dataQuality: {
|
||||
coverage: "good",
|
||||
gaps: [],
|
||||
confidence: "hoch",
|
||||
},
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(insight: InsightResult) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Test card" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<InsightAdvisorCard> — inline chart tokens", () => {
|
||||
it("renders an inline HealthChart for an allowlisted token in summary", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "BP looking great metric:BLOOD_PRESSURE_SYS today",
|
||||
}),
|
||||
);
|
||||
|
||||
// Chart is rendered with the token's metric as the type.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="BLOOD_PRESSURE_SYS"');
|
||||
|
||||
// Token literal is stripped from the visible prose.
|
||||
expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS");
|
||||
|
||||
// Surrounding prose survives unchanged.
|
||||
expect(html).toContain("BP looking great");
|
||||
expect(html).toContain("today");
|
||||
});
|
||||
|
||||
it("drops hallucinated tokens silently and still strips the literal", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:NUKE try this",
|
||||
}),
|
||||
);
|
||||
|
||||
// No chart rendered.
|
||||
expect(html).not.toContain('data-testid="health-chart"');
|
||||
|
||||
// Token literal is gone from the DOM.
|
||||
expect(html).not.toContain("metric:NUKE");
|
||||
|
||||
// Visible text is the surrounding prose only.
|
||||
expect(html).toContain("try this");
|
||||
});
|
||||
|
||||
it("treats injection attempts as plain text — no script, no markup", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:WEIGHT' onclick='alert(1)'",
|
||||
}),
|
||||
);
|
||||
|
||||
// The chart still renders for the safe metric prefix.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
|
||||
// The trailing junk is preserved as escaped text — no script tag,
|
||||
// no live `onclick` attribute survives the React text path.
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toMatch(/<[^>]*\sonclick\s*=/i);
|
||||
|
||||
// The escaped remnant still appears in the prose so the user can see
|
||||
// what the model emitted (alert(1) is now inert HTML-encoded text).
|
||||
expect(html).toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("renders an inline chart attached to a hero finding token", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Pulse trending down metric:PULSE",
|
||||
value: "68 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="PULSE"');
|
||||
expect(html).not.toContain("metric:PULSE");
|
||||
expect(html).toContain("Pulse trending down");
|
||||
});
|
||||
|
||||
it("renders inline charts for secondary findings without leaking tokens", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Hero finding",
|
||||
value: "120/80",
|
||||
assessment: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Weight stable metric:WEIGHT",
|
||||
value: "82 kg",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Weight stable");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { HealthChart } from "@/components/charts/health-chart";
|
||||
import {
|
||||
parseChartTokens,
|
||||
stripChartTokens,
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -34,36 +41,34 @@ interface InsightAdvisorCardProps {
|
||||
|
||||
// ─── Classification Colors ───────────────────────────────
|
||||
|
||||
const CLASSIFICATION_STYLES: Record<
|
||||
string,
|
||||
{ badge: string; border: string }
|
||||
> = {
|
||||
optimal: {
|
||||
badge:
|
||||
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
|
||||
border: "border-l-dracula-green",
|
||||
},
|
||||
gut: {
|
||||
badge:
|
||||
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
|
||||
border: "border-l-dracula-cyan",
|
||||
},
|
||||
grenzwertig: {
|
||||
badge:
|
||||
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
|
||||
border: "border-l-dracula-yellow",
|
||||
},
|
||||
erhoht: {
|
||||
badge:
|
||||
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
|
||||
border: "border-l-dracula-orange",
|
||||
},
|
||||
kritisch: {
|
||||
badge:
|
||||
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
|
||||
border: "border-l-dracula-red",
|
||||
},
|
||||
};
|
||||
const CLASSIFICATION_STYLES: Record<string, { badge: string; border: string }> =
|
||||
{
|
||||
optimal: {
|
||||
badge:
|
||||
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
|
||||
border: "border-l-dracula-green",
|
||||
},
|
||||
gut: {
|
||||
badge:
|
||||
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
|
||||
border: "border-l-dracula-cyan",
|
||||
},
|
||||
grenzwertig: {
|
||||
badge:
|
||||
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
|
||||
border: "border-l-dracula-yellow",
|
||||
},
|
||||
erhoht: {
|
||||
badge:
|
||||
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
|
||||
border: "border-l-dracula-orange",
|
||||
},
|
||||
kritisch: {
|
||||
badge:
|
||||
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
|
||||
border: "border-l-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Assessment Icons ─────────────────────────────────────
|
||||
|
||||
@@ -74,16 +79,104 @@ function AssessmentIcon({
|
||||
}) {
|
||||
switch (assessment) {
|
||||
case "positive":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-dracula-green" />;
|
||||
return <CheckCircle2 className="text-dracula-green h-4 w-4 shrink-0" />;
|
||||
case "neutral":
|
||||
return <Minus className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
return <Minus className="text-muted-foreground h-4 w-4 shrink-0" />;
|
||||
case "attention":
|
||||
return <AlertCircle className="h-4 w-4 shrink-0 text-dracula-orange" />;
|
||||
return <AlertCircle className="text-dracula-orange h-4 w-4 shrink-0" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 shrink-0 text-dracula-red" />;
|
||||
return <AlertTriangle className="text-dracula-red h-4 w-4 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hero Finding ─────────────────────────────────────────
|
||||
|
||||
const HERO_STYLES: Record<
|
||||
InsightFinding["assessment"],
|
||||
{ wrapper: string; icon: string; label: string }
|
||||
> = {
|
||||
positive: {
|
||||
wrapper:
|
||||
"from-dracula-green/15 via-dracula-green/5 to-transparent border-l-dracula-green",
|
||||
icon: "text-dracula-green",
|
||||
label: "text-dracula-green",
|
||||
},
|
||||
neutral: {
|
||||
wrapper:
|
||||
"from-dracula-cyan/12 via-dracula-cyan/4 to-transparent border-l-dracula-cyan",
|
||||
icon: "text-dracula-cyan",
|
||||
label: "text-dracula-cyan",
|
||||
},
|
||||
attention: {
|
||||
wrapper:
|
||||
"from-dracula-orange/15 via-dracula-orange/5 to-transparent border-l-dracula-orange",
|
||||
icon: "text-dracula-orange",
|
||||
label: "text-dracula-orange",
|
||||
},
|
||||
warning: {
|
||||
wrapper:
|
||||
"from-dracula-red/15 via-dracula-red/5 to-transparent border-l-dracula-red",
|
||||
icon: "text-dracula-red",
|
||||
label: "text-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
function HeroFinding({ finding }: { finding: InsightFinding }) {
|
||||
const { t } = useTranslations();
|
||||
const style = HERO_STYLES[finding.assessment];
|
||||
const labelKey =
|
||||
finding.assessment === "positive"
|
||||
? "insights.heroFindingPositive"
|
||||
: finding.assessment === "warning"
|
||||
? "insights.heroFindingWarning"
|
||||
: finding.assessment === "attention"
|
||||
? "insights.heroFindingAttention"
|
||||
: "insights.heroFindingNeutral";
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const cleanLabel = stripChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const cleanGuideline = finding.guideline
|
||||
? stripChartTokens(finding.guideline)
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
data-slot="insight-hero-finding"
|
||||
className={`rounded-lg border-l-2 bg-gradient-to-br ${style.wrapper} px-4 py-3.5`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<div className={`mt-0.5 shrink-0 ${style.icon}`}>
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className={`text-xs font-semibold tracking-widest uppercase ${style.label}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</p>
|
||||
<p className="text-sm leading-snug font-medium">{cleanLabel}</p>
|
||||
{cleanGuideline && (
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{cleanGuideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-base leading-snug font-semibold tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{(labelTokens.length > 0 || guidelineTokens.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<InlineCharts tokens={[...labelTokens, ...guidelineTokens]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Confidence Colors ────────────────────────────────────
|
||||
|
||||
const CONFIDENCE_STYLES: Record<string, string> = {
|
||||
@@ -98,13 +191,76 @@ const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
|
||||
gering: "insights.confidenceGering",
|
||||
};
|
||||
|
||||
// ─── Inline Chart Renderer ────────────────────────────────
|
||||
//
|
||||
// Expand a list of allowlisted ChartTokens into HealthChart instances.
|
||||
// HealthChart already understands the `MeasurementType`-style strings
|
||||
// (WEIGHT, BLOOD_PRESSURE_SYS, …); the synthetic MOOD and COMPLIANCE
|
||||
// tokens map to the dedicated chart components shipped elsewhere — for
|
||||
// now we render them as a HealthChart with the token's metric name as
|
||||
// the type, which the chart will gracefully render empty if there's no
|
||||
// matching data, instead of silently swallowing the user's expectation.
|
||||
//
|
||||
// Tokens are deduped per call so the prose `metric:WEIGHT … metric:WEIGHT`
|
||||
// doesn't render the same chart twice in a row.
|
||||
|
||||
const INLINE_CHART_TITLE_KEYS: Record<string, string> = {
|
||||
WEIGHT: "charts.weight",
|
||||
BLOOD_PRESSURE_SYS: "charts.systolic",
|
||||
BLOOD_PRESSURE_DIA: "charts.diastolic",
|
||||
PULSE: "charts.pulse",
|
||||
BODY_FAT: "charts.bodyFat",
|
||||
SLEEP_DURATION: "charts.sleep",
|
||||
ACTIVITY_STEPS: "charts.steps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
TOTAL_BODY_WATER: "charts.bodyWater",
|
||||
BONE_MASS: "charts.boneMass",
|
||||
OXYGEN_SATURATION: "charts.spo2",
|
||||
MOOD: "insights.navMood",
|
||||
COMPLIANCE: "insights.navMedication",
|
||||
};
|
||||
|
||||
function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
|
||||
const { t } = useTranslations();
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
// Dedup while preserving the order the model emitted them.
|
||||
const seen = new Set<ChartToken>();
|
||||
const unique: ChartToken[] = [];
|
||||
for (const token of tokens) {
|
||||
if (!seen.has(token)) {
|
||||
seen.add(token);
|
||||
unique.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="insight-inline-charts" className="space-y-3">
|
||||
{unique.map((token) => {
|
||||
const metric = tokenToMetric(token);
|
||||
const titleKey = INLINE_CHART_TITLE_KEYS[metric];
|
||||
const title = titleKey ? t(titleKey) : metric;
|
||||
return (
|
||||
<div
|
||||
key={token}
|
||||
data-slot="insight-inline-chart"
|
||||
data-metric={metric}
|
||||
>
|
||||
<HealthChart types={[metric]} title={title} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
|
||||
function SectionSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider font-medium text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
|
||||
<span>{label}</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +282,8 @@ export function InsightAdvisorCard({
|
||||
const [dataQualityOpen, setDataQualityOpen] = useState(false);
|
||||
|
||||
const classStyle = insight
|
||||
? CLASSIFICATION_STYLES[insight.classification] ??
|
||||
CLASSIFICATION_STYLES.gut
|
||||
? (CLASSIFICATION_STYLES[insight.classification] ??
|
||||
CLASSIFICATION_STYLES.gut)
|
||||
: CLASSIFICATION_STYLES.gut;
|
||||
|
||||
// ── Loading State ─────────────────────────────────────
|
||||
@@ -135,8 +291,8 @@ export function InsightAdvisorCard({
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-dracula-purple" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="text-dracula-purple h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">
|
||||
{t("insights.generating")}
|
||||
</span>
|
||||
</CardContent>
|
||||
@@ -151,26 +307,24 @@ export function InsightAdvisorCard({
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.noAnalysisYet")}
|
||||
</p>
|
||||
{onRegenerate && (
|
||||
@@ -202,9 +356,7 @@ export function InsightAdvisorCard({
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
@@ -231,13 +383,13 @@ export function InsightAdvisorCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
@@ -245,43 +397,70 @@ export function InsightAdvisorCard({
|
||||
|
||||
{/* Primary Recommendation */}
|
||||
{insight.primaryRecommendation && (
|
||||
<div className="rounded-md border-l-2 border-dracula-purple bg-dracula-purple/5 px-4 py-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-widest text-dracula-purple">
|
||||
<div className="border-dracula-purple bg-dracula-purple/5 rounded-md border-l-2 px-4 py-3">
|
||||
<p className="text-dracula-purple mb-1 text-xs font-medium tracking-widest uppercase">
|
||||
{t("insights.keyTakeaway")}
|
||||
</p>
|
||||
<p className="text-sm">{insight.primaryRecommendation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{insight.summary}
|
||||
</p>
|
||||
{/* Summary — prose first, then any allowlisted charts the model
|
||||
* inlined via `metric:<TYPE>` tokens. Tokens are dropped from the
|
||||
* visible text so the literal substring never reaches the DOM. */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{stripChartTokens(insight.summary)}
|
||||
</p>
|
||||
<InlineCharts tokens={parseChartTokens(insight.summary)} />
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
{/* Findings — top finding gets a hero treatment, rest are a compact list */}
|
||||
{insight.findings.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SectionSeparator label={t("insights.findingsTitle")} />
|
||||
<div className="space-y-2">
|
||||
{insight.findings.map((finding, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">{finding.label}</span>
|
||||
{/* v1.4 — pull the first finding into a severity-tinted hero
|
||||
* card. The model is instructed to put the most clinically
|
||||
* relevant finding first; we let the UI honour that by giving
|
||||
* it more weight than the secondary findings.
|
||||
*/}
|
||||
<HeroFinding finding={insight.findings[0]} />
|
||||
{insight.findings.length > 1 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{insight.findings.slice(1).map((finding, i) => {
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const tokens = [...labelTokens, ...guidelineTokens];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">
|
||||
{stripChartTokens(finding.label)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{finding.guideline && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
|
||||
{stripChartTokens(finding.guideline)}
|
||||
</p>
|
||||
)}
|
||||
{tokens.length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<InlineCharts tokens={tokens} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{finding.guideline && (
|
||||
<p className="ml-6 mt-0.5 text-xs text-muted-foreground">
|
||||
{finding.guideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -293,7 +472,7 @@ export function InsightAdvisorCard({
|
||||
{insight.correlations.map((corr, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-xs"
|
||||
className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<span>{corr.factor}</span>
|
||||
<span className="text-muted-foreground">↔</span>
|
||||
@@ -316,7 +495,7 @@ export function InsightAdvisorCard({
|
||||
<ol className="space-y-1.5 pl-0">
|
||||
{insight.recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
<span className="shrink-0 font-medium text-muted-foreground">
|
||||
<span className="text-muted-foreground shrink-0 font-medium">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{rec}</span>
|
||||
@@ -332,7 +511,7 @@ export function InsightAdvisorCard({
|
||||
<button
|
||||
onClick={() => setDataQualityOpen((v) => !v)}
|
||||
aria-expanded={dataQualityOpen}
|
||||
className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-1.5 text-xs transition-colors"
|
||||
>
|
||||
{dataQualityOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
@@ -355,7 +534,7 @@ export function InsightAdvisorCard({
|
||||
</button>
|
||||
|
||||
{dataQualityOpen && (
|
||||
<div className="mt-2 space-y-2 rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground animate-insight-in">
|
||||
<div className="bg-muted/30 text-muted-foreground animate-insight-in mt-2 space-y-2 rounded-md px-3 py-2.5 text-xs">
|
||||
<p>{insight.dataQuality.coverage}</p>
|
||||
{insight.dataQuality.gaps.length > 0 && (
|
||||
<div>
|
||||
@@ -373,17 +552,15 @@ export function InsightAdvisorCard({
|
||||
)}
|
||||
|
||||
{cachedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start gap-1.5 pt-1">
|
||||
<Info className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{insight.disclaimer}
|
||||
</p>
|
||||
<Info className="text-muted-foreground mt-0.5 h-3 w-3 shrink-0" />
|
||||
<p className="text-muted-foreground text-xs">{insight.disclaimer}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -158,7 +158,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
{gravatarUrl && (
|
||||
<AvatarImage src={gravatarUrl} alt={user.username} />
|
||||
@@ -193,7 +196,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="text-muted-foreground hover:text-foreground hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{dropdownContent}
|
||||
@@ -364,7 +370,9 @@ export function SidebarNav() {
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/bugreport"
|
||||
aria-current={pathname === "/bugreport" ? "page" : undefined}
|
||||
aria-current={
|
||||
pathname === "/bugreport" ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname === "/bugreport"
|
||||
@@ -382,11 +390,13 @@ export function SidebarNav() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname === "/settings"
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
@@ -415,11 +425,13 @@ export function SidebarNav() {
|
||||
{t("nav.bugreport")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/settings"
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function TopBar() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="cursor-pointer">
|
||||
<Link href="/settings/account" className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, Bell, Check, Pill, User2, Wifi, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
buildChecklist,
|
||||
checklistProgress,
|
||||
shouldShowChecklist,
|
||||
visibleChecklist,
|
||||
type ChecklistItemId,
|
||||
} from "@/lib/onboarding/checklist";
|
||||
|
||||
const DISMISSED_ITEMS_KEY = "healthlog-getting-started-dismissed";
|
||||
const DISMISSED_ALL_KEY = "healthlog-getting-started-hidden";
|
||||
|
||||
const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
|
||||
profile: User2,
|
||||
measurement: Activity,
|
||||
medication: Pill,
|
||||
withings: Wifi,
|
||||
notifications: Bell,
|
||||
};
|
||||
|
||||
const ITEM_LABEL_KEYS: Record<
|
||||
ChecklistItemId,
|
||||
{ title: string; description: string; cta: string }
|
||||
> = {
|
||||
profile: {
|
||||
title: "gettingStarted.items.profileTitle",
|
||||
description: "gettingStarted.items.profileDescription",
|
||||
cta: "gettingStarted.items.profileCta",
|
||||
},
|
||||
measurement: {
|
||||
title: "gettingStarted.items.measurementTitle",
|
||||
description: "gettingStarted.items.measurementDescription",
|
||||
cta: "gettingStarted.items.measurementCta",
|
||||
},
|
||||
medication: {
|
||||
title: "gettingStarted.items.medicationTitle",
|
||||
description: "gettingStarted.items.medicationDescription",
|
||||
cta: "gettingStarted.items.medicationCta",
|
||||
},
|
||||
withings: {
|
||||
title: "gettingStarted.items.withingsTitle",
|
||||
description: "gettingStarted.items.withingsDescription",
|
||||
cta: "gettingStarted.items.withingsCta",
|
||||
},
|
||||
notifications: {
|
||||
title: "gettingStarted.items.notificationsTitle",
|
||||
description: "gettingStarted.items.notificationsDescription",
|
||||
cta: "gettingStarted.items.notificationsCta",
|
||||
},
|
||||
};
|
||||
|
||||
interface MedicationsResponse {
|
||||
data?: { medications?: Array<{ id: string }> };
|
||||
}
|
||||
|
||||
interface AnalyticsResponse {
|
||||
data?: {
|
||||
summaries?: Record<string, { count?: number } | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
interface WithingsStatusResponse {
|
||||
data?: { connected?: boolean };
|
||||
}
|
||||
|
||||
interface NotificationChannel {
|
||||
type?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationsResponse {
|
||||
data?: { channels?: NotificationChannel[] };
|
||||
}
|
||||
|
||||
function readDismissedSet(): Set<ChecklistItemId> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DISMISSED_ITEMS_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(
|
||||
parsed.filter((id): id is ChecklistItemId => typeof id === "string"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore corrupt storage */
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function readDismissedAll(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(DISMISSED_ALL_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent dashboard checklist surfaced to brand-new users. Mirrors
|
||||
* the Linear/Notion pattern: visible until the user has either
|
||||
* completed every step or explicitly hidden it. Each row is one line
|
||||
* with status icon + label + CTA + dismiss-x.
|
||||
*/
|
||||
export function GettingStartedChecklist() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<ChecklistItemId>>(() =>
|
||||
readDismissedSet(),
|
||||
);
|
||||
const [dismissedAll, setDismissedAll] = useState<boolean>(() =>
|
||||
readDismissedAll(),
|
||||
);
|
||||
|
||||
// Sync state to localStorage. Effect, not setState-in-effect.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
DISMISSED_ITEMS_KEY,
|
||||
JSON.stringify([...dismissedIds]),
|
||||
);
|
||||
} catch {
|
||||
/* storage may be full or disabled */
|
||||
}
|
||||
}, [dismissedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(DISMISSED_ALL_KEY, dismissedAll ? "1" : "0");
|
||||
} catch {
|
||||
/* storage may be full or disabled */
|
||||
}
|
||||
}, [dismissedAll]);
|
||||
|
||||
// Light-weight queries: each fetch is small and cached by tanstack.
|
||||
// We rely on the React Query cache the dashboard already uses for
|
||||
// analytics, so this won't fire a second request when the dashboard
|
||||
// and the checklist mount together.
|
||||
const { data: analyticsRes } = useQuery<AnalyticsResponse>({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: medsRes } = useQuery<MedicationsResponse>({
|
||||
queryKey: ["medications"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/medications");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: withingsRes } = useQuery<WithingsStatusResponse>({
|
||||
queryKey: ["withings", "status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/withings/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const { data: notificationsRes } = useQuery<NotificationsResponse>({
|
||||
queryKey: ["notifications", "preferences"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/notifications/preferences");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const measurementCount = useMemo(() => {
|
||||
const summaries = analyticsRes?.data?.summaries ?? {};
|
||||
let total = 0;
|
||||
for (const key of Object.keys(summaries)) {
|
||||
total += summaries[key]?.count ?? 0;
|
||||
}
|
||||
return total;
|
||||
}, [analyticsRes]);
|
||||
|
||||
const medicationCount = medsRes?.data?.medications?.length ?? 0;
|
||||
const withingsConnected = withingsRes?.data?.connected === true;
|
||||
const notificationsConfigured = (notificationsRes?.data?.channels ?? []).some(
|
||||
(channel) => channel?.enabled === true,
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildChecklist({
|
||||
profile: {
|
||||
heightCm: user?.heightCm ?? null,
|
||||
dateOfBirth: user?.dateOfBirth ?? null,
|
||||
gender: user?.gender ?? null,
|
||||
},
|
||||
measurementCount,
|
||||
medicationCount,
|
||||
withingsConnected,
|
||||
notificationsConfigured,
|
||||
dismissedIds,
|
||||
}),
|
||||
[
|
||||
user?.heightCm,
|
||||
user?.dateOfBirth,
|
||||
user?.gender,
|
||||
measurementCount,
|
||||
medicationCount,
|
||||
withingsConnected,
|
||||
notificationsConfigured,
|
||||
dismissedIds,
|
||||
],
|
||||
);
|
||||
|
||||
const visible = visibleChecklist(items);
|
||||
const progress = checklistProgress(items);
|
||||
|
||||
const show = shouldShowChecklist({
|
||||
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
|
||||
measurementCount,
|
||||
dismissedAll,
|
||||
items,
|
||||
});
|
||||
|
||||
if (!user || !show) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="getting-started-title"
|
||||
className="bg-card border-border space-y-4 rounded-2xl border p-5 sm:p-6"
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2
|
||||
id="getting-started-title"
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t("gettingStarted.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("gettingStarted.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setDismissedAll(true)}
|
||||
>
|
||||
{t("gettingStarted.dismissAll")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* Progress meter — non-decorative, exposed to screen readers. */}
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress.percent}
|
||||
aria-label={t("gettingStarted.progress", {
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
})}
|
||||
className="space-y-1"
|
||||
>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>
|
||||
{t("gettingStarted.progress", {
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
})}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums">{progress.percent}%</span>
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-[width] duration-200 ease-out motion-reduce:transition-none"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
{visible.map((item) => {
|
||||
const Icon = ITEM_ICONS[item.id];
|
||||
const labels = ITEM_LABEL_KEYS[item.id];
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className="hover:bg-accent/40 group flex items-center gap-3 rounded-md px-2 py-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
item.done
|
||||
? "bg-primary/20 text-primary flex size-7 shrink-0 items-center justify-center rounded-full"
|
||||
: "bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full"
|
||||
}
|
||||
>
|
||||
{item.done ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={
|
||||
item.done
|
||||
? "text-muted-foreground truncate text-sm line-through"
|
||||
: "truncate text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{t(labels.title)}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{t(labels.description)}
|
||||
</p>
|
||||
</div>
|
||||
{!item.done ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={item.href}>{t(labels.cta)}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDismissedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(item.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
aria-label={t("gettingStarted.dismissTooltip")}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-1 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// Stub out next/navigation. The settings sections that read it (Account)
|
||||
// only call `useRouter().push()` from inside an effect after mount, so a
|
||||
// no-op stub is enough for SSR smoke rendering.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/account",
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — every section reads from it but we only need the
|
||||
// SSR markup, not live data. Returning a stable empty payload keeps the
|
||||
// "loading" branch from spinning forever and lets the section title render.
|
||||
// The About section reads `["api", "version"]` and gates the body on data
|
||||
// presence — return a representative payload so the version card paints.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
if (
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "api" &&
|
||||
queryKey[1] === "version"
|
||||
) {
|
||||
return {
|
||||
data: {
|
||||
version: "1.4.0",
|
||||
buildSha: "abc1234567",
|
||||
builtAt: "2026-05-08T12:00:00Z",
|
||||
license: "AGPL-3.0",
|
||||
repository: "https://github.com/MBombeck/HealthLog",
|
||||
changelog:
|
||||
"https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
docs: "https://docs.healthlog.dev",
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return { data: null, isLoading: false, refetch: vi.fn() };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// Stub the auth hook — sections check `isAuthenticated` to enable queries.
|
||||
// We return `isAuthenticated: true` + a minimal user so the loading-spinner
|
||||
// branch in AccountSection paints the real form.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
heightCm: 180,
|
||||
dateOfBirth: "1990-01-01",
|
||||
gender: "MALE",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Sub-sections of Dashboard own complex data fetching that's already
|
||||
// covered by their own tests. Stub at the import boundary.
|
||||
vi.mock("@/components/settings/dashboard-layout-section", () => ({
|
||||
DashboardLayoutSection: () => <div data-testid="dashboard-layout" />,
|
||||
}));
|
||||
vi.mock("@/components/settings/thresholds-section", () => ({
|
||||
ThresholdsSection: () => <div data-testid="thresholds" />,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AboutSection } from "../about-section";
|
||||
import { AccountSection } from "../account-section";
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
import { AiSection } from "../ai-section";
|
||||
import { ApiSection } from "../api-section";
|
||||
import { DashboardSection } from "../dashboard-section";
|
||||
import { IntegrationsSection } from "../integrations-section";
|
||||
import { NotificationsSection } from "../notifications-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("settings sections — SSR smoke", () => {
|
||||
it("<AccountSection> renders the Account heading and Profile card", () => {
|
||||
const html = render(<AccountSection />);
|
||||
// Section title resolves via the i18n provider.
|
||||
expect(html).toContain("Account");
|
||||
// Profile card heading is also painted.
|
||||
expect(html).toContain("Profile");
|
||||
// No raw key leaks past the i18n layer.
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
|
||||
it("<AboutSection> renders version + license + check-for-updates button", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("About");
|
||||
// The new about.* keys resolve.
|
||||
expect(html).toContain("Check for updates");
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
it("<AiSection> renders the AI Insights card", () => {
|
||||
const html = render(<AiSection />);
|
||||
expect(html).toContain("AI Insights");
|
||||
});
|
||||
|
||||
it("<IntegrationsSection> renders Withings card title", () => {
|
||||
const html = render(<IntegrationsSection />);
|
||||
expect(html).toContain("Integrations");
|
||||
expect(html).toContain("Withings");
|
||||
});
|
||||
|
||||
it("<NotificationsSection> renders heading", () => {
|
||||
const html = render(<NotificationsSection />);
|
||||
expect(html).toContain("Notifications");
|
||||
});
|
||||
|
||||
it("<DashboardSection> renders heading and embeds layout + thresholds", () => {
|
||||
const html = render(<DashboardSection />);
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain('data-testid="dashboard-layout"');
|
||||
expect(html).toContain('data-testid="thresholds"');
|
||||
});
|
||||
|
||||
it("<ApiSection> renders endpoints + tokens cards", () => {
|
||||
const html = render(<ApiSection />);
|
||||
expect(html).toContain("API & Tokens");
|
||||
});
|
||||
|
||||
it("<AdvancedSection> renders export + danger-zone cards", () => {
|
||||
const html = render(<AdvancedSection />);
|
||||
expect(html).toContain("Advanced");
|
||||
});
|
||||
|
||||
it("<AboutSection> resolves about.* keys in German", () => {
|
||||
const html = render(<AboutSection />, "de");
|
||||
expect(html).toContain("Auf Updates prüfen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// `<SettingsShell>` reads the active route from `usePathname()`. We stub
|
||||
// next/navigation so the SSR test render works without an App-Router
|
||||
// runtime. The mock reads `mockPathnameRef.value` at call-time (not at
|
||||
// module-load) so each test can mutate the ref before rendering and the
|
||||
// shell will see the updated path.
|
||||
const mockPathnameRef = { value: "/settings/account" };
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathnameRef.value,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
SETTINGS_SECTIONS,
|
||||
SettingsShell,
|
||||
isSettingsSectionSlug,
|
||||
} from "../settings-shell";
|
||||
|
||||
function renderShell(props: {
|
||||
active?: (typeof SETTINGS_SECTION_SLUGS)[number];
|
||||
pathname?: string;
|
||||
locale?: "en" | "de";
|
||||
}) {
|
||||
mockPathnameRef.value = props.pathname ?? "/settings/account";
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={props.locale ?? "en"}>
|
||||
<SettingsShell active={props.active}>
|
||||
<div>section body</div>
|
||||
</SettingsShell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
it("declares exactly the eight sections agreed for v1.4", () => {
|
||||
// Order matters — `generateStaticParams()` and the sidebar derive their
|
||||
// ordering from this constant, so a reorder is a behaviour change.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"ai",
|
||||
"api",
|
||||
"advanced",
|
||||
"about",
|
||||
]);
|
||||
});
|
||||
|
||||
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
expect(isSettingsSectionSlug(slug)).toBe(true);
|
||||
}
|
||||
expect(isSettingsSectionSlug("nope")).toBe(false);
|
||||
expect(isSettingsSectionSlug("")).toBe(false);
|
||||
expect(isSettingsSectionSlug("Account")).toBe(false); // case-sensitive
|
||||
});
|
||||
|
||||
it("each section has a non-empty title key under settings.sections.<slug>", () => {
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(section.titleKey).toBe(`settings.sections.${section.slug}.title`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SettingsShell>", () => {
|
||||
it("renders all eight section links — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
||||
// Two renders — mobile strip + desktop sidebar — guarantee the link
|
||||
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
|
||||
// but the markup is always present so it's keyboard-discoverable
|
||||
// before media queries resolve.
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
// Spot-check a couple of slugs that have historic anchor aliases (e.g.
|
||||
// `/settings#withings` → `/settings/integrations`) — they must NOT
|
||||
// sneak back in.
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
expect(html).not.toContain('href="/settings#');
|
||||
expect(html).not.toContain('href="/settings"');
|
||||
});
|
||||
|
||||
it('marks the active section with aria-current="page" (and only that one)', () => {
|
||||
const html = renderShell({ active: "notifications" });
|
||||
// Both layouts emit the active link, so two `aria-current="page"`.
|
||||
const occurrences = html.match(/aria-current="page"/g);
|
||||
expect(occurrences?.length ?? 0).toBe(2);
|
||||
|
||||
// And the active link is on the notifications href. Attribute order
|
||||
// in React SSR is alphabetic, so `aria-current` precedes `href` —
|
||||
// the regex deliberately doesn't pin order to stay robust against
|
||||
// future React renderer changes.
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("derives the active slug from the current pathname when `active` is omitted", () => {
|
||||
const html = renderShell({ pathname: "/settings/api" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to `account` when the pathname doesn't match a known slug", () => {
|
||||
const html = renderShell({ pathname: "/settings/totally-bogus" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — English", () => {
|
||||
const html = renderShell({ active: "account", locale: "en" });
|
||||
expect(html).toContain("Account");
|
||||
expect(html).toContain("Integrations");
|
||||
expect(html).toContain("Notifications");
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain("AI Insights");
|
||||
// The ampersand is HTML-escaped by React SSR — assert on the encoded
|
||||
// form so we don't accidentally match a parser that double-escapes.
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Advanced");
|
||||
expect(html).toContain("About");
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — German", () => {
|
||||
const html = renderShell({ active: "account", locale: "de" });
|
||||
expect(html).toContain("Konto");
|
||||
expect(html).toContain("Integrationen");
|
||||
expect(html).toContain("Benachrichtigungen");
|
||||
expect(html).toContain("Übersicht");
|
||||
expect(html).toContain("KI-Auswertungen");
|
||||
// API & Tokens is identical in both locales (proper noun + ampersand)
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Erweitert");
|
||||
expect(html).toContain("Über");
|
||||
});
|
||||
|
||||
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
|
||||
const html = renderShell({ active: "account", locale: "en" });
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Info,
|
||||
Loader2,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface VersionPayload {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
license: string;
|
||||
repository: string;
|
||||
changelog: string;
|
||||
docs: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RELEASES_API_URL =
|
||||
"https://api.github.com/repos/MBombeck/HealthLog/releases/latest";
|
||||
|
||||
/**
|
||||
* Compare a semantic version (e.g. "1.4.0") with a release tag (e.g.
|
||||
* "v1.4.0", "1.4.1"). Returns `true` when `tag` is strictly newer than
|
||||
* `current`. Falls back to lexical compare when the strings don't parse,
|
||||
* which is intentional — better to surface "newer available" on a parse miss
|
||||
* than silently swallow a release.
|
||||
*/
|
||||
function isNewer(current: string, tag: string): boolean {
|
||||
const stripV = (s: string) => s.replace(/^v/i, "").trim();
|
||||
const a = stripV(current)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const b = stripV(tag)
|
||||
.split(/[.-]/)
|
||||
.map((n) => Number.parseInt(n, 10));
|
||||
const maxLen = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const ai = a[i] ?? 0;
|
||||
const bi = b[i] ?? 0;
|
||||
if (Number.isNaN(ai) || Number.isNaN(bi)) {
|
||||
// Fall back to lexical compare for pre-release suffixes etc.
|
||||
return stripV(tag) > stripV(current);
|
||||
}
|
||||
if (bi > ai) return true;
|
||||
if (bi < ai) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data: version, isLoading } = useQuery({
|
||||
queryKey: ["api", "version"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("version-fetch-failed");
|
||||
const json = await res.json();
|
||||
return json.data as VersionPayload;
|
||||
},
|
||||
// The endpoint is `force-static` — version doesn't change at runtime.
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updateResult, setUpdateResult] = useState<
|
||||
| { kind: "up_to_date" }
|
||||
| { kind: "newer"; tag: string; url: string }
|
||||
| { kind: "error" }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
async function handleCheckForUpdates() {
|
||||
if (!version) return;
|
||||
setChecking(true);
|
||||
setUpdateResult(null);
|
||||
try {
|
||||
const res = await fetch(RELEASES_API_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setUpdateResult({ kind: "error" });
|
||||
return;
|
||||
}
|
||||
const release = (await res.json()) as GithubRelease;
|
||||
if (isNewer(version.version, release.tag_name)) {
|
||||
setUpdateResult({
|
||||
kind: "newer",
|
||||
tag: release.tag_name,
|
||||
url: release.html_url,
|
||||
});
|
||||
} else {
|
||||
setUpdateResult({ kind: "up_to_date" });
|
||||
}
|
||||
} catch {
|
||||
setUpdateResult({ kind: "error" });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-about-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-about-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.about.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.about.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Info className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">HealthLog</h2>
|
||||
</div>
|
||||
|
||||
{isLoading || !version ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.version")}
|
||||
</dt>
|
||||
<dd className="font-mono text-sm font-medium">
|
||||
v{version.version}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Scale className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.license")}
|
||||
</dt>
|
||||
<dd>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{version.license}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{version.buildSha && (
|
||||
<div className="flex items-start gap-3">
|
||||
<GitBranch className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.gitSha")}
|
||||
</dt>
|
||||
<dd className="font-mono text-sm">
|
||||
{version.buildSha.slice(0, 7)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{version.builtAt && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("settings.about.builtAt", {
|
||||
time: fmt.dateTime(version.builtAt),
|
||||
})}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{fmt.date(version.builtAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={version.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
{t("settings.about.repository")}
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={version.changelog}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t("settings.about.changelog")}
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={version.docs} target="_blank" rel="noopener noreferrer">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
{t("settings.about.docs")}
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check for updates */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.about.checkUpdates")}
|
||||
</Button>
|
||||
|
||||
{updateResult?.kind === "up_to_date" && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("settings.about.upToDate")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.kind === "newer" && (
|
||||
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
|
||||
<Sparkles className="text-dracula-purple h-4 w-4" />
|
||||
<a
|
||||
href={updateResult.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.tag,
|
||||
})}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.kind === "error" && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("settings.testConnection.errors.connection_failed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Save,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialDeviceType: string;
|
||||
credentialBackedUp: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function AccountSection() {
|
||||
const { t, locale, setLocale } = useTranslations();
|
||||
const { user, isLoading, isAuthenticated, refetch } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [heightCm, setHeightCm] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState<string>("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Password change dialog state.
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [passwordMsg, setPasswordMsg] = useState<string | null>(null);
|
||||
const [passwordMsgType, setPasswordMsgType] = useState<
|
||||
"success" | "error" | null
|
||||
>(null);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
// Passkey registration state.
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [passkeyMsg, setPasskeyMsg] = useState<string | null>(null);
|
||||
const [passkeyMsgType, setPasskeyMsgType] = useState<
|
||||
"success" | "error" | null
|
||||
>(null);
|
||||
|
||||
// Auth gate — push back to login if the user is unauthenticated. Effect
|
||||
// intentionally only navigates (never sets state in a way that re-runs the
|
||||
// effect), so the lint rule is satisfied without a disable.
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
// Hydrate the form draft once the auth payload arrives. Using the
|
||||
// React-recommended "store-the-prop-id alongside the state" pattern so the
|
||||
// sync-from-server happens during render (not in a setState-in-effect),
|
||||
// satisfying the strict `react-hooks/set-state-in-effect` rule.
|
||||
const [seededUserId, setSeededUserId] = useState<string | null>(null);
|
||||
if (user && user.id !== seededUserId) {
|
||||
setSeededUserId(user.id);
|
||||
setEmail(user.email ?? "");
|
||||
setHeightCm(user.heightCm?.toString() ?? "");
|
||||
setDateOfBirth(
|
||||
user.dateOfBirth
|
||||
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
|
||||
: "",
|
||||
);
|
||||
setGender(user.gender ?? "");
|
||||
}
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: email.trim() || null,
|
||||
heightCm: heightCm ? parseFloat(heightCm) : null,
|
||||
dateOfBirth: dateOfBirth || null,
|
||||
gender: gender || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSaveMsg(t("settings.profileSaved"));
|
||||
setSaveMsgType("success");
|
||||
await refetch();
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setSaveMsg(json.error || t("settings.savingError"));
|
||||
setSaveMsgType("error");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleAddPasskey() {
|
||||
setPasskeyLoading(true);
|
||||
setPasskeyMsg(null);
|
||||
setPasskeyMsgType(null);
|
||||
|
||||
try {
|
||||
const optRes = await fetch("/api/auth/passkey/register-options", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!optRes.ok) {
|
||||
setPasskeyMsg(t("settings.passkeyOptionsError"));
|
||||
setPasskeyMsgType("error");
|
||||
setPasskeyLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const optJson = await optRes.json();
|
||||
const { options, challengeId } = optJson.data;
|
||||
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
|
||||
const verifyRes = await fetch("/api/auth/passkey/register-verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ challengeId, credential }),
|
||||
});
|
||||
|
||||
if (verifyRes.ok) {
|
||||
setPasskeyMsg(t("settings.passkeyAdded"));
|
||||
setPasskeyMsgType("success");
|
||||
} else {
|
||||
const verifyJson = await verifyRes.json();
|
||||
setPasskeyMsg(
|
||||
verifyJson.error || t("settings.passkeyRegistrationFailed"),
|
||||
);
|
||||
setPasskeyMsgType("error");
|
||||
}
|
||||
} catch (err) {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setPasskeyMsg(t(key, params));
|
||||
setPasskeyMsgType("error");
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPasswordSaving(true);
|
||||
setPasswordMsg(null);
|
||||
setPasswordMsgType(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg(t("settings.passwordMismatch"));
|
||||
setPasswordMsgType("error");
|
||||
setPasswordSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setPasswordMsg(json.error || t("settings.savingError"));
|
||||
setPasswordMsgType("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordMsg(t("settings.passwordUpdated"));
|
||||
setPasswordMsgType("success");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setPasswordMsg(t("common.networkError"));
|
||||
setPasswordMsgType("error");
|
||||
} finally {
|
||||
setPasswordSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-account-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-account-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.account.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.account.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Profile card */}
|
||||
<div 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.languageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
{/* Passkeys card */}
|
||||
<div className="bg-card border-border 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>
|
||||
</div>
|
||||
{passkeyMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-right text-sm ${
|
||||
passkeyMsgType === "success"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{passkeyMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password card */}
|
||||
<div className="bg-card border-border 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>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: ["passkeys"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/passkeys");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as PasskeyInfo[];
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const deletePasskey = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/auth/passkeys/${id}`, { method: "DELETE" });
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || t("common.error"));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
setDeleteMsg(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setDeleteMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
};
|
||||
|
||||
if (!passkeys || passkeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{t("settings.passkeys")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.registeredPasskeys")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.passkeysDescription")}
|
||||
</p>
|
||||
<div className="border-border mt-3 overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[620px] text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyDevice")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyBackup")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.passkeyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{passkeys.map((pk, idx) => (
|
||||
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{pk.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(pk.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
disabled={deletePasskey.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{deleteMsg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-2 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{deleteMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function AdvancedSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-advanced-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-advanced-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.advanced.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.advanced.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ExportCard />
|
||||
<DataResetCard />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportCard() {
|
||||
const { t, locale } = useTranslations();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [generatingReport, setGeneratingReport] = useState(false);
|
||||
|
||||
async function handleExport(format: "csv" | "json") {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/export?format=${format}&type=all`);
|
||||
if (!res.ok) return;
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-export-${new Date().toISOString().slice(0, 10)}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDoctorReport() {
|
||||
setGeneratingReport(true);
|
||||
try {
|
||||
const res = await fetch("/api/doctor-report", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ days: 90 }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
|
||||
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`);
|
||||
} finally {
|
||||
setGeneratingReport(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.export")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.exportDescription")}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("json")}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
{t("settings.exportJson")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("csv")}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
{t("settings.exportCsv")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDoctorReport}
|
||||
disabled={generatingReport}
|
||||
>
|
||||
{generatingReport && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("settings.doctorReport")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataResetCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
async function handleDeleteAllData() {
|
||||
setDeleting(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/data", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setMsg(json.error || t("settings.dangerZoneDeleteFailed"));
|
||||
setMsgType("error");
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries();
|
||||
setMsg(t("settings.dangerZoneSuccess"));
|
||||
setMsgType("success");
|
||||
} catch {
|
||||
setMsg(t("settings.dangerZoneDeleteFailed"));
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||
<h2 className="text-destructive text-lg font-semibold">
|
||||
{t("settings.dangerZone")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.dangerZoneDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" disabled={deleting}>
|
||||
{deleting ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.dangerZone")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.dangerZoneConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.dangerZoneConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDeleteAllData}
|
||||
>
|
||||
{t("settings.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, RefreshCw, Save, Sparkles, Trash2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
codexConnectedAt: string | null;
|
||||
hasAdminKey: boolean;
|
||||
privacyMode: string;
|
||||
lastInsightAt: string | null;
|
||||
}
|
||||
|
||||
interface UserAIProvider {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
baseUrl: string | null;
|
||||
hasAnthropicKey: boolean;
|
||||
anthropicKeyPreview: string | null;
|
||||
hasLocalKey: boolean;
|
||||
}
|
||||
|
||||
export function AiSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-ai-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.ai.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.ai.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<InsightsSettingsCard isAuthenticated={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightsSettingsCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["insights", "settings"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/settings");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as InsightsSettings;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: async (body: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/insights/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
},
|
||||
});
|
||||
|
||||
// OAuth callback handler — reads `?codex_connected=true|codex_error=...`
|
||||
// from the URL and surfaces a toast-equivalent inline message. We do the
|
||||
// read at render time (via a `useState` lazy initialiser) so the strict
|
||||
// `react-hooks/set-state-in-effect` rule isn't triggered. The effect then
|
||||
// drains the result by clearing the URL params and invalidating queries.
|
||||
const [oauthOutcome] = useState<
|
||||
{ kind: "connected" } | { kind: "error" } | null
|
||||
>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("codex_connected") === "true") return { kind: "connected" };
|
||||
if (params.get("codex_error")) return { kind: "error" };
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthOutcome) return;
|
||||
// The browser-history rewrite is the side effect; the matching i18n
|
||||
// message is already painted via the lazy initialiser below.
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
}
|
||||
}, [oauthOutcome, queryClient]);
|
||||
|
||||
// Seed the initial inline message from the OAuth outcome. Reads happen
|
||||
// during render (allowed) — `setMsg` is only called by user actions after
|
||||
// mount, never inside an effect.
|
||||
const [oauthMsgSeeded, setOauthMsgSeeded] = useState(false);
|
||||
if (!oauthMsgSeeded && oauthOutcome) {
|
||||
setOauthMsgSeeded(true);
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
setMsg(t("settings.codexConnected"));
|
||||
setMsgType("success");
|
||||
} else {
|
||||
setMsg(t("settings.codexConnectionFailed"));
|
||||
setMsgType("error");
|
||||
}
|
||||
}
|
||||
|
||||
const hasProvider =
|
||||
settings?.codexStatus === "connected" || settings?.hasAdminKey;
|
||||
|
||||
function handleConnect() {
|
||||
window.location.href = "/api/auth/codex/authorize";
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
setDisconnecting(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/codex/disconnect", {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error);
|
||||
}
|
||||
setMsg(t("settings.codexDisconnected"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePrivacyMode() {
|
||||
const newMode = settings?.privacyMode === "raw" ? "aggregated" : "raw";
|
||||
await updateSettings.mutateAsync({ privacyMode: newMode });
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
setRegenerating(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await fetch("/api/insights/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ force: true }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
setMsg(t("settings.regenerateRateLimit"));
|
||||
} else {
|
||||
setMsg(json.error || t("settings.savingError"));
|
||||
}
|
||||
setMsgType("error");
|
||||
return;
|
||||
}
|
||||
setMsg(t("settings.regenerateSuccess"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
} catch {
|
||||
setMsg(t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.kiInsights")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{settings?.codexStatus === "connected" && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
ChatGPT verbunden
|
||||
</Badge>
|
||||
)}
|
||||
{settings?.codexStatus !== "connected" && settings?.hasAdminKey && (
|
||||
<Badge className="border-dracula-purple/30 bg-dracula-purple/15 text-dracula-purple">
|
||||
Admin-KI aktiv
|
||||
</Badge>
|
||||
)}
|
||||
{settings?.codexStatus === "expired" && (
|
||||
<Badge className="border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange">
|
||||
Verbindung abgelaufen
|
||||
</Badge>
|
||||
)}
|
||||
{settings?.lastInsightAt && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("settings.lastGeneratedAt")}:{" "}
|
||||
{formatDateTime(settings.lastInsightAt)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.kiInsightsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
{settings?.codexStatus === "connected" ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<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.
|
||||
{settings.codexConnectedAt && (
|
||||
<>
|
||||
{" "}
|
||||
Verbunden seit {formatDateTime(settings.codexConnectedAt)}
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive shrink-0"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
>
|
||||
{disconnecting ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
Trennen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasProvider && (
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<p className="text-sm font-medium">{t("settings.rawData")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{settings?.privacyMode === "raw"
|
||||
? t("settings.rawDataOnDescription")
|
||||
: t("settings.rawDataOffDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-2 shrink-0">
|
||||
<Switch
|
||||
checked={settings?.privacyMode === "raw"}
|
||||
onCheckedChange={togglePrivacyMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{settings?.privacyMode === "raw" && (
|
||||
<div className="mt-2 rounded-lg bg-orange-500/10 p-2 text-xs text-orange-400">
|
||||
{t("settings.rawDataWarning")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasProvider && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.regenerateInsights")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<UserAIProviderSubsection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAIProviderSubsection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [provider, setProvider] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [baseUrl, setBaseUrl] = useState<string>("");
|
||||
const [anthropicKey, setAnthropicKey] = useState<string>("");
|
||||
const [localKey, setLocalKey] = useState<string>("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveOk, setSaveOk] = useState<boolean>(false);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
const [testOk, setTestOk] = useState<boolean>(false);
|
||||
const [testing, setTesting] = useState<boolean>(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user", "ai-provider"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/ai-provider");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as UserAIProvider;
|
||||
},
|
||||
});
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const dataKey = data
|
||||
? `${data.provider ?? ""}|${data.model ?? ""}|${data.baseUrl ?? ""}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (dataKey && dataKey !== seededKey) {
|
||||
setSeededKey(dataKey);
|
||||
setProvider(data!.provider ?? "");
|
||||
setModel(data!.model ?? "");
|
||||
setBaseUrl(data!.baseUrl ?? "");
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
provider: provider || null,
|
||||
model: model || null,
|
||||
baseUrl: baseUrl || null,
|
||||
};
|
||||
if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim();
|
||||
if (localKey.trim()) body.localKey = localKey.trim();
|
||||
const res = await fetch("/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || "Save failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMsg("Gespeichert");
|
||||
setSaveOk(true);
|
||||
setAnthropicKey("");
|
||||
setLocalKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
setSaveMsg(e instanceof Error ? e.message : "Fehler");
|
||||
setSaveOk(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function runTest() {
|
||||
setTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
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}`);
|
||||
setTestOk(false);
|
||||
return;
|
||||
}
|
||||
setTestMsg(
|
||||
`OK — ${json.data.providerType} (${json.data.model})${
|
||||
json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : ""
|
||||
}`,
|
||||
);
|
||||
setTestOk(true);
|
||||
} catch (e) {
|
||||
setTestMsg(e instanceof Error ? e.message : "Test fehlgeschlagen");
|
||||
setTestOk(false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 mt-2 rounded-lg p-4">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium">KI-Provider (persönlich)</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen
|
||||
für Standard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="ai-provider-select">Provider</Label>
|
||||
<select
|
||||
id="ai-provider-select"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
>
|
||||
<option value="">— Standard (Admin/Codex) —</option>
|
||||
<option value="OPENAI">OpenAI</option>
|
||||
<option value="ANTHROPIC">Anthropic (Claude)</option>
|
||||
<option value="LOCAL">Lokal (OpenAI-kompatibel)</option>
|
||||
<option value="CHATGPT_OAUTH">ChatGPT OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ai-model-input">Modell</Label>
|
||||
<Input
|
||||
id="ai-model-input"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === "ANTHROPIC"
|
||||
? "claude-3-5-sonnet-latest"
|
||||
: provider === "LOCAL"
|
||||
? "llama3:8b"
|
||||
: "gpt-4o-mini"
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "ANTHROPIC" && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-anthropic-key">
|
||||
Anthropic API Key
|
||||
{data?.hasAnthropicKey && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
(gespeichert {data.anthropicKeyPreview})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ai-anthropic-key"
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "LOCAL" && (
|
||||
<>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-base-url">Base URL</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-local-key">API Key (optional)</Label>
|
||||
<PasswordInput
|
||||
id="ai-local-key"
|
||||
value={localKey}
|
||||
onChange={(e) => setLocalKey(e.target.value)}
|
||||
placeholder={data?.hasLocalKey ? "(gespeichert)" : ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={runTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
saveOk ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
{testMsg && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
testOk ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{testMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Key, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface ApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
export function ApiSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="settings-section-api-title" className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-api-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.api.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.api.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ApiEndpointsCard />
|
||||
<ApiTokensCard />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiEndpointsCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.apiEndpointsTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.apiEndpointsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointMethod")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointPath")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointAuth")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.apiEndpointExample")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-3 py-2 font-medium">POST</td>
|
||||
<td className="px-3 py-2 font-mono">/api/ingest/medication</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
Authorization: Bearer hlk_...
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
{`{ "medicationName": "...", "scheduledFor": "..." }`}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
|
||||
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
|
||||
|
||||
const { data: tokens } = useQuery({
|
||||
queryKey: ["tokens"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/tokens");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as ApiTokenInfo[];
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setTokenMsg(null);
|
||||
setNewToken(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setNewToken(json.data.token);
|
||||
setNewName("");
|
||||
queryClient.invalidateQueries({ queryKey: ["tokens"] });
|
||||
} else {
|
||||
setTokenMsg(json.error || t("common.error"));
|
||||
}
|
||||
} catch {
|
||||
setTokenMsg(t("common.networkError"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(tokenId: string) {
|
||||
const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: ["tokens"] });
|
||||
}
|
||||
}
|
||||
|
||||
const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked);
|
||||
const revokedTokens = (tokens ?? []).filter((tok) => tok.revoked);
|
||||
const latestActiveUse = activeTokens.reduce<string | null>((latest, tok) => {
|
||||
if (!tok.lastUsedAt) return latest;
|
||||
if (!latest || new Date(tok.lastUsedAt) > new Date(latest)) {
|
||||
return tok.lastUsedAt;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.apiTokens")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{activeTokens.length > 0 && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("settings.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{latestActiveUse && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("settings.tokenTableLastUsed")}:{" "}
|
||||
{formatDateTime(latestActiveUse)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.apiTokensDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div className="bg-dracula-green/10 rounded-lg p-3 text-sm">
|
||||
<p className="text-dracula-green mb-1 font-medium">
|
||||
{t("settings.tokenCreated")}
|
||||
</p>
|
||||
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
|
||||
{newToken}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenMsg && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{tokenMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableStatus")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.tokenTableActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{activeTokens.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="text-muted-foreground px-3 py-4 text-center text-sm"
|
||||
>
|
||||
{t("settings.noActiveTokens")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{activeTokens.map((tok, index) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{revokedTokens.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRevokedTokens((prev) => !prev)}
|
||||
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{t("settings.revokedTokensTitle", {
|
||||
count: revokedTokens.length,
|
||||
})}
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<div className="border-border overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function DashboardSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-dashboard-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-dashboard-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.dashboard.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.dashboard.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
<ThresholdsSection id="thresholds" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Download,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Smile,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
interface MoodLogStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
entryCount: number;
|
||||
webhookSecret: string | null;
|
||||
}
|
||||
|
||||
interface GlobalServiceAvailability {
|
||||
telegramGlobal: boolean;
|
||||
ntfyGlobal: boolean;
|
||||
webPushGlobal: boolean;
|
||||
apiGlobal: boolean;
|
||||
moodLogGlobal: boolean;
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: globalServices } = useQuery({
|
||||
queryKey: ["settings", "global-services"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/global-services");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as GlobalServiceAvailability;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-integrations-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-integrations-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.integrations.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<WithingsCard isAuthenticated={isAuthenticated} />
|
||||
{moodLogEnabled && <MoodLogCard />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
const [credsMsg, setCredsMsg] = useState<string | null>(null);
|
||||
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ["withings", "status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/withings/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
lastSyncedAt?: string | null;
|
||||
connectedAt?: string;
|
||||
tokenExpired?: boolean;
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/withings/disconnect", { method: "POST" });
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["withings"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
try {
|
||||
const res = await fetch("/api/withings/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(
|
||||
fullSync
|
||||
? t("settings.withingsFullSyncResult", {
|
||||
count: json.data.imported,
|
||||
})
|
||||
: t("settings.withingsSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t("settings.withingsSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCredsSaving(true);
|
||||
setCredsMsg(null);
|
||||
setCredsMsgType(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/withings/credentials", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setCredsMsg(t("settings.withingsCredentialsSaved"));
|
||||
setCredsMsgType("success");
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
queryClient.invalidateQueries({ queryKey: ["withings"] });
|
||||
} else {
|
||||
try {
|
||||
const json = await res.json();
|
||||
setCredsMsg(json.error || t("settings.savingError"));
|
||||
} catch {
|
||||
setCredsMsg(t("settings.savingError"));
|
||||
}
|
||||
setCredsMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setCredsMsg(t("common.networkError"));
|
||||
setCredsMsgType("error");
|
||||
}
|
||||
setCredsSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.withings")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{status?.configured && !status?.connected && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("settings.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{status?.connected && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("settings.withingsConnected")}
|
||||
</Badge>
|
||||
)}
|
||||
{status?.lastSyncedAt && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("settings.withingsLastSync")}:{" "}
|
||||
{formatDateTime(status.lastSyncedAt)}
|
||||
</Badge>
|
||||
)}
|
||||
{status?.tokenExpired && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.withingsTokenExpired")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.withingsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.withingsCredentials")}
|
||||
</h3>
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="w-clientid">
|
||||
{t("settings.withingsClientId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="w-clientid"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.withingsCredentialsSavedPlaceholder")
|
||||
: t("settings.withingsClientId")
|
||||
}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="w-secret">
|
||||
{t("settings.withingsClientSecret")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="w-secret"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.withingsCredentialsSavedPlaceholderSecret")
|
||||
: t("settings.withingsClientSecret")
|
||||
}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="invisible">{t("common.save")}</Label>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full"
|
||||
disabled={
|
||||
credsSaving || !clientId.trim() || !clientSecret.trim()
|
||||
}
|
||||
>
|
||||
{credsSaving ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.withingsSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${credsMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{credsMsg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{status?.connected ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSync(false)}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.withingsSync")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={syncing}>
|
||||
{syncing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.withingsFullSync")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.withingsFullSyncTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.withingsFullSyncDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleSync(true)}>
|
||||
{t("settings.withingsSynchronize")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/withings/test"
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Unlink className="mr-1 h-3.5 w-3.5" />
|
||||
{t("settings.withingsDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.withingsDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.withingsDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.withingsDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/withings/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
{t("settings.withingsConnect")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
{t("settings.withingsNoCredentials")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoodLogCard() {
|
||||
const { t } = useTranslations();
|
||||
const [url, setUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: status, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ["moodlog-status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/integrations/moodlog/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as MoodLogStatus;
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
const res = await fetch("/api/settings/moodlog", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.moodLogSaved"));
|
||||
setMsgType("success");
|
||||
setUrl("");
|
||||
setApiKey("");
|
||||
await refetchStatus();
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/integrations/moodlog/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setMsg(
|
||||
t("settings.moodLogSyncResult").replace(
|
||||
"{count}",
|
||||
String(json.data.imported),
|
||||
),
|
||||
);
|
||||
setMsgType("success");
|
||||
await refetchStatus();
|
||||
} else {
|
||||
setMsg(t("settings.moodLogSyncFailed"));
|
||||
setMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setMsg(t("settings.moodLogSyncFailed"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setSyncing(false);
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
const res = await fetch("/api/settings/moodlog", { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.moodLogDisconnected"));
|
||||
setMsgType("success");
|
||||
await refetchStatus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border 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"
|
||||
>
|
||||
{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")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.moodLogDescription")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-3">
|
||||
<div>
|
||||
<Label>{t("settings.moodLogUrl")}</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder={t("settings.moodLogUrlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.moodLogApiKey")}</Label>
|
||||
<PasswordInput
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.withingsCredentialsSavedPlaceholder")
|
||||
: t("settings.moodLogApiKeyPlaceholder")
|
||||
}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<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")}
|
||||
</Button>
|
||||
<TestConnectionButton
|
||||
endpoint="/api/integrations/moodlog/test"
|
||||
disabled={!status?.configured}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{status?.configured && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{status.webhookSecret && (
|
||||
<div>
|
||||
<Label>{t("settings.moodLogWebhookSecret")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={status.webhookSecret}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(status.webhookSecret!);
|
||||
setMsg(t("common.copied"));
|
||||
setMsgType("success");
|
||||
}}
|
||||
>
|
||||
{t("common.copied").replace("!", "")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.moodLogWebhookSecretHelp")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{status.lastSyncedAt && (
|
||||
<span>
|
||||
{t("settings.moodLogLastSync")}:{" "}
|
||||
{formatDateTime(status.lastSyncedAt)}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t("settings.moodLogEntries")}: {status.entryCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={syncing}
|
||||
onClick={() => handleSync(false)}
|
||||
>
|
||||
{syncing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("settings.moodLogSync")}
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={syncing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t("settings.moodLogFullSync")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.moodLogFullSyncTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.moodLogFullSyncDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleSync(true)}>
|
||||
{t("settings.moodLogFullSync")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Unlink className="mr-2 h-4 w-4" />
|
||||
{t("settings.moodLogDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.moodLogDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.moodLogDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDisconnect}>
|
||||
{t("settings.moodLogDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${msgType === "error" ? "text-destructive" : "text-dracula-green"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { NtfyCard } from "@/components/settings/ntfy-card";
|
||||
import { TelegramCard } from "@/components/settings/telegram-card";
|
||||
import { WebPushCard } from "@/components/settings/web-push-card";
|
||||
|
||||
interface GlobalServiceAvailability {
|
||||
telegramGlobal: boolean;
|
||||
ntfyGlobal: boolean;
|
||||
webPushGlobal: boolean;
|
||||
apiGlobal: boolean;
|
||||
moodLogGlobal: boolean;
|
||||
}
|
||||
|
||||
export function NotificationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: globalServices } = useQuery({
|
||||
queryKey: ["settings", "global-services"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/global-services");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as GlobalServiceAvailability;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const showTelegram = globalServices?.telegramGlobal ?? true;
|
||||
const showNtfy = globalServices?.ntfyGlobal ?? true;
|
||||
const showWebPush = globalServices?.webPushGlobal ?? true;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-notifications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-notifications-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.notifications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.notifications.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{showTelegram && <TelegramCard isAuthenticated={isAuthenticated} />}
|
||||
{showNtfy && <NtfyCard isAuthenticated={isAuthenticated} />}
|
||||
{showWebPush && <WebPushCard />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Loader2, Send } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface NtfySettings {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
topic: string;
|
||||
hasAuthToken: boolean;
|
||||
}
|
||||
|
||||
export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [serverUrl, setServerUrl] = useState("https://ntfy.sh");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings", "ntfy"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/ntfy");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as NtfySettings;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.serverUrl}|${settings.topic}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setServerUrl(settings!.serverUrl);
|
||||
setTopic(settings!.topic);
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
const res = await fetch("/api/settings/ntfy", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
serverUrl,
|
||||
topic,
|
||||
authToken: authToken || undefined,
|
||||
enabled,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error || t("common.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "ntfy"] });
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
setAuthToken("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/settings/ntfy/test", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error || t("common.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMsg(t("settings.testSent"));
|
||||
setSaveMsgType("success");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.ntfy")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{settings?.serverUrl && settings?.topic && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("settings.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{settings?.enabled && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("common.enabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ntfyDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
|
||||
<Switch
|
||||
id="ntfy-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => save.mutate(checked)}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate(settings?.enabled ?? false);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-server">{t("settings.ntfyServer")}</Label>
|
||||
<Input
|
||||
id="ntfy-server"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-topic">{t("settings.ntfyTopic")}</Label>
|
||||
<Input
|
||||
id="ntfy-topic"
|
||||
placeholder="healthlog-mein-topic"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="ntfy-auth">{t("settings.ntfyAuthToken")}</Label>
|
||||
<PasswordInput
|
||||
id="ntfy-auth"
|
||||
placeholder={
|
||||
settings?.hasAuthToken
|
||||
? t("settings.saved")
|
||||
: t("common.optional")
|
||||
}
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ntfyAuthTokenHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${saveMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={test.isPending || !settings?.enabled}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{test.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.testMessage")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={save.isPending}>
|
||||
{save.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
/**
|
||||
* Password input with a built-in show/hide toggle. Extracted from the legacy
|
||||
* settings monolith so every section that needs a masked secret (Withings,
|
||||
* moodLog, ntfy, Telegram, AI provider keys, account password) can share the
|
||||
* same UX without duplicating the visibility-toggle plumbing.
|
||||
*/
|
||||
export function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} type={visible ? "text" : "password"} />
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||
>
|
||||
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SectionPlaceholder>` — the temporary body of every `/settings/[section]`
|
||||
* route until the matching extraction PR lands.
|
||||
*
|
||||
* The shell + routing surface ship in PR A2-shell so reviewers can validate
|
||||
* the URL structure in isolation. The follow-up PRs (A2-account, A2-about,
|
||||
* A2-ai, A2-integrations, A2-notifications, A2-rest) replace this placeholder
|
||||
* with the real content for each slug, copying from
|
||||
* `src/app/settings/page.legacy.tsx`.
|
||||
*/
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Info,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SettingsSectionSlug } from "./section-slugs";
|
||||
|
||||
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
account: User,
|
||||
integrations: Link2,
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
advanced: Settings2,
|
||||
about: Info,
|
||||
};
|
||||
|
||||
export interface SectionPlaceholderProps {
|
||||
slug: SettingsSectionSlug;
|
||||
}
|
||||
|
||||
export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
|
||||
const { t } = useTranslations();
|
||||
const Icon = SLUG_ICON[slug];
|
||||
const sectionTitle = t(`settings.sections.${slug}.title`);
|
||||
const sectionDescription = t(`settings.sections.${slug}.description`);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`settings-section-${slug}-title`}
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id={`settings-section-${slug}-title`}
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{sectionTitle}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{sectionDescription}</p>
|
||||
</header>
|
||||
|
||||
<EmptyState
|
||||
icon={<Icon className="size-6" />}
|
||||
title={sectionTitle}
|
||||
description={t("settings.sections.placeholder.coming_soon")}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Server-safe constants for the v1.4 settings split. Kept in a separate
|
||||
* module from `<SettingsShell>` (a client component) so server-rendered code
|
||||
* paths — `generateStaticParams()`, the page guard, future tests — can import
|
||||
* the slug list without dragging the whole client bundle into the server
|
||||
* graph.
|
||||
*/
|
||||
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"ai",
|
||||
"api",
|
||||
"advanced",
|
||||
"about",
|
||||
] as const;
|
||||
|
||||
export type SettingsSectionSlug = (typeof SETTINGS_SECTION_SLUGS)[number];
|
||||
|
||||
export function isSettingsSectionSlug(
|
||||
value: string,
|
||||
): value is SettingsSectionSlug {
|
||||
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SettingsShell>` — sidebar + main column shell for the v1.4 settings split.
|
||||
*
|
||||
* Each section lives at its own route (`/settings/[section]/page.tsx`) and
|
||||
* mounts inside this shell. The shell owns nothing but navigation: the active
|
||||
* section is read from the route, the section heading + content are rendered
|
||||
* by the page itself.
|
||||
*
|
||||
* Mobile-first per `docs/ui-guidelines.md`:
|
||||
* - <md: section selector renders as a horizontal scroll strip at the top so
|
||||
* a thumb-tap can swap sections without leaving the viewport.
|
||||
* - >=md: sticky 220px sidebar + content column with `max-w-screen-xl`.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
Info,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "./section-slugs";
|
||||
|
||||
// Re-export so existing call-sites importing from this module keep working.
|
||||
// New server-side call-sites (e.g. `generateStaticParams()`) should import
|
||||
// from `./section-slugs` directly, since this file is `"use client"` and
|
||||
// values exported from a client module become unusable proxies on the
|
||||
// server. (Discovered the hard way: `SETTINGS_SECTION_SLUGS.map(...)` in
|
||||
// `generateStaticParams()` blows up at build time when imported through a
|
||||
// client boundary.)
|
||||
export {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
};
|
||||
|
||||
interface SettingsSection {
|
||||
slug: SettingsSectionSlug;
|
||||
/** i18n key under `settings.sections.<slug>.title`. */
|
||||
titleKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for the section list. Order matches the in-app navigation
|
||||
* order so the sidebar, mobile strip, and `generateStaticParams()` all line
|
||||
* up. Don't reorder without updating tests.
|
||||
*/
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
|
||||
{
|
||||
slug: "integrations",
|
||||
titleKey: "settings.sections.integrations.title",
|
||||
icon: Link2,
|
||||
},
|
||||
{
|
||||
slug: "notifications",
|
||||
titleKey: "settings.sections.notifications.title",
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
slug: "dashboard",
|
||||
titleKey: "settings.sections.dashboard.title",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
|
||||
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
|
||||
{
|
||||
slug: "advanced",
|
||||
titleKey: "settings.sections.advanced.title",
|
||||
icon: Settings2,
|
||||
},
|
||||
{ slug: "about", titleKey: "settings.sections.about.title", icon: Info },
|
||||
] as const;
|
||||
|
||||
export interface SettingsShellProps {
|
||||
/**
|
||||
* Optional override for the active section. When omitted the shell reads
|
||||
* the active slug from `usePathname()` (the production behaviour). Tests
|
||||
* pass an explicit value so they don't need to mock the router.
|
||||
*/
|
||||
active?: SettingsSectionSlug;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function deriveActiveSlug(
|
||||
pathname: string | null,
|
||||
override?: SettingsSectionSlug,
|
||||
): SettingsSectionSlug {
|
||||
if (override) return override;
|
||||
if (!pathname) return "account";
|
||||
// Match `/settings/<slug>` and ignore any trailing segments (none today,
|
||||
// but cheap insurance for future nested routes).
|
||||
const match = pathname.match(/^\/settings\/([^/]+)/);
|
||||
const candidate = match?.[1] ?? "";
|
||||
return isSettingsSectionSlug(candidate) ? candidate : "account";
|
||||
}
|
||||
|
||||
export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 py-6 md:px-6 md:py-8">
|
||||
{/* Mobile section strip — horizontal scroll, hidden on md+. */}
|
||||
<nav
|
||||
aria-label={t("settings.shell.sectionsNav")}
|
||||
className="-mx-4 mb-4 overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(section.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
||||
{/* Desktop sticky sidebar */}
|
||||
<aside
|
||||
aria-label={t("settings.shell.sectionsNav")}
|
||||
className="hidden md:block"
|
||||
>
|
||||
<div className="sticky top-20">
|
||||
<ul className="space-y-1">
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(section.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main column — page renders its own h1 + subtitle */}
|
||||
<main className="min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MessageCircle, Save, Send } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/settings/password-input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface TelegramSettings {
|
||||
enabled: boolean;
|
||||
hasBotToken: boolean;
|
||||
chatId: string | null;
|
||||
}
|
||||
|
||||
export function TelegramCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["telegram", "settings"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/telegram");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as TelegramSettings;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// Sync from server using the React-recommended "previous-payload-id"
|
||||
// pattern instead of setState-in-effect. This avoids the strict
|
||||
// `react-hooks/set-state-in-effect` lint failure that the legacy file
|
||||
// disabled at module scope.
|
||||
const settingsKey = settings
|
||||
? `${settings.enabled}|${settings.chatId ?? ""}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setEnabled(settings!.enabled);
|
||||
if (settings!.chatId) setChatId(settings!.chatId);
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
const body: Record<string, unknown> = { enabled };
|
||||
if (botToken.trim()) body.botToken = botToken.trim();
|
||||
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
|
||||
|
||||
const res = await fetch("/api/settings/telegram", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.telegramSaved"));
|
||||
setMsgType("success");
|
||||
setBotToken("");
|
||||
queryClient.invalidateQueries({ queryKey: ["telegram"] });
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
const res = await fetch("/api/settings/telegram/test", { method: "POST" });
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.testSent"));
|
||||
setMsgType("success");
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("common.error"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.telegram")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{settings?.hasBotToken && settings?.chatId && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("settings.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{settings?.enabled && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("common.enabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.telegramDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tg-token">{t("settings.botToken")}</Label>
|
||||
<PasswordInput
|
||||
id="tg-token"
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
placeholder={
|
||||
settings?.hasBotToken
|
||||
? t("settings.withingsCredentialsSavedPlaceholder")
|
||||
: "123456:ABC-DEF..."
|
||||
}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tg-chatid">{t("settings.chatId")}</Label>
|
||||
<Input
|
||||
id="tg-chatid"
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
placeholder="123456789"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="tg-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor="tg-enabled" className="cursor-pointer">
|
||||
{t("settings.enableNotifications")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-xs">
|
||||
<p>{t("settings.telegramStep1")}</p>
|
||||
<p>{t("settings.telegramStep2")}</p>
|
||||
<p>{t("settings.telegramStep3")}</p>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
msgType === "success"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={testing || !settings?.hasBotToken}
|
||||
onClick={handleTest}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.testMessage")}
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Shared "Test connection" UX for the settings integrations + notifications
|
||||
* sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error,
|
||||
* meta }` envelope, and surfaces success (latency) or a translated
|
||||
* `meta.errorCode` callout.
|
||||
*
|
||||
* Endpoints are user-scoped, so the button intentionally does NOT send an
|
||||
* Idempotency-Key — each click probes the upstream live.
|
||||
*/
|
||||
export interface TestConnectionButtonProps {
|
||||
endpoint: string;
|
||||
/** Optional disabled flag from the parent — typically "no credentials yet". */
|
||||
disabled?: boolean;
|
||||
/** Label override for the button (defaults to settings.testConnection.test). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface TestResponse {
|
||||
data?: { latencyMs?: number; ok?: boolean; sent?: number };
|
||||
error?: string;
|
||||
meta?: { errorCode?: string };
|
||||
}
|
||||
|
||||
export function TestConnectionButton({
|
||||
endpoint,
|
||||
disabled = false,
|
||||
label,
|
||||
}: TestConnectionButtonProps) {
|
||||
const { t } = useTranslations();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<
|
||||
| { kind: "ok"; latency: number }
|
||||
| { kind: "error"; errorCode: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
async function handleClick() {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(endpoint, { method: "POST" });
|
||||
const json = (await res.json().catch(() => ({}))) as TestResponse;
|
||||
|
||||
if (res.ok && json.data?.ok !== false) {
|
||||
setResult({
|
||||
kind: "ok",
|
||||
latency: json.data?.latencyMs ?? 0,
|
||||
});
|
||||
} else {
|
||||
setResult({
|
||||
kind: "error",
|
||||
errorCode: json.meta?.errorCode ?? "generic",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setResult({ kind: "error", errorCode: "connection_failed" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function describeError(errorCode: string): string {
|
||||
const key = `settings.testConnection.errors.${errorCode}`;
|
||||
const translated = t(key);
|
||||
// `t()` returns the raw key when missing — fall back to generic.
|
||||
if (translated === key) {
|
||||
return t("settings.testConnection.errors.generic");
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
disabled={disabled || testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plug className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{testing
|
||||
? t("settings.testConnection.testing")
|
||||
: (label ?? t("settings.testConnection.test"))}
|
||||
</Button>
|
||||
|
||||
{result?.kind === "ok" && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t("settings.testConnection.ok", { latency: result.latency })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{result?.kind === "error" && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{describeError(result.errorCode)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
|
||||
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const buffer = new ArrayBuffer(raw.length);
|
||||
const view = new Uint8Array(buffer);
|
||||
for (let i = 0; i < raw.length; ++i) {
|
||||
view[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function WebPushCard() {
|
||||
const { t } = useTranslations();
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isSupported, setIsSupported] = useState(true);
|
||||
const [isDenied, setIsDenied] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Async I/O (browser API + service worker registration). The lint rule
|
||||
// allows setState inside async callbacks — only synchronous setState in
|
||||
// the effect body is rejected.
|
||||
async function checkSubscription() {
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
setIsSupported(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "denied") {
|
||||
setIsDenied(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration =
|
||||
await navigator.serviceWorker.getRegistration("/sw.js");
|
||||
if (registration) {
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setIsSubscribed(!!subscription);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during check
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
void checkSubscription();
|
||||
}, []);
|
||||
|
||||
async function handleSubscribe() {
|
||||
setActionLoading(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
try {
|
||||
const vapidRes = await fetch("/api/notifications/vapid");
|
||||
if (!vapidRes.ok) {
|
||||
setMsg(t("settings.webPushNotConfigured"));
|
||||
setMsgType("error");
|
||||
setActionLoading(false);
|
||||
return;
|
||||
}
|
||||
const vapidData = await vapidRes.json();
|
||||
const vapidPublicKey = vapidData.data.publicKey;
|
||||
|
||||
const registration = await navigator.serviceWorker.register("/sw.js");
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
|
||||
});
|
||||
|
||||
const subJson = subscription.toJSON();
|
||||
|
||||
const res = await fetch("/api/notifications/web-push", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
endpoint: subJson.endpoint,
|
||||
keys: {
|
||||
p256dh: subJson.keys?.p256dh ?? "",
|
||||
auth: subJson.keys?.auth ?? "",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setIsSubscribed(true);
|
||||
setMsg(t("settings.webPushSubscribed"));
|
||||
setMsgType("success");
|
||||
} else {
|
||||
setMsg(t("settings.webPushSubscribeFailed"));
|
||||
setMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
if (Notification.permission === "denied") {
|
||||
setIsDenied(true);
|
||||
setMsg(t("settings.webPushDenied"));
|
||||
} else {
|
||||
setMsg(t("settings.webPushSubscribeFailed"));
|
||||
}
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnsubscribe() {
|
||||
setActionLoading(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
try {
|
||||
const registration =
|
||||
await navigator.serviceWorker.getRegistration("/sw.js");
|
||||
if (registration) {
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await fetch("/api/notifications/web-push", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubscribed(false);
|
||||
setMsg(t("settings.webPushUnsubscribed"));
|
||||
setMsgType("success");
|
||||
} catch {
|
||||
setMsg(t("settings.webPushSubscribeFailed"));
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("settings.webPush")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!loading && isSubscribed && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("settings.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
{!loading && isSubscribed && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("settings.webPushActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.webPushDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : !isSupported ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.webPushNotSupported")}
|
||||
</p>
|
||||
) : isDenied ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("settings.webPushDenied")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{isSubscribed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUnsubscribe}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("settings.webPushUnsubscribe")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSubscribe}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<BellRing className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.webPushSubscribe")}
|
||||
</Button>
|
||||
)}
|
||||
{isSubscribed && (
|
||||
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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]",
|
||||
|
||||
@@ -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 };
|
||||
@@ -3,6 +3,10 @@ import type { Instrumentation } from "next";
|
||||
export async function register() {
|
||||
// Only start the worker on the Node.js server runtime (not Edge, not build)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { shouldRunWorker } = await import("@/lib/process-type");
|
||||
// Web-only container — the dedicated worker service runs the queues.
|
||||
if (!shouldRunWorker()) return;
|
||||
|
||||
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
|
||||
const { emitIfSampled } = await import("@/lib/logging/transports");
|
||||
|
||||
|
||||
@@ -1,70 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { encrypt, decrypt } from "../crypto";
|
||||
import {
|
||||
encrypt,
|
||||
decrypt,
|
||||
reencryptToActive,
|
||||
extractKeyId,
|
||||
getActiveKeyId,
|
||||
_resetCryptoCacheForTests,
|
||||
} from "../crypto";
|
||||
|
||||
describe("crypto (AES-256-GCM)", () => {
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_V2 =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
describe("crypto (AES-256-GCM, key versioning)", () => {
|
||||
beforeEach(() => {
|
||||
// Set a deterministic encryption key for tests (64 hex chars = 32 bytes)
|
||||
vi.stubEnv(
|
||||
"ENCRYPTION_KEY",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
);
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
it("encrypts and decrypts back to original", () => {
|
||||
it("encrypts and decrypts back to original (legacy single-key path)", () => {
|
||||
const plaintext = "my-secret-access-token-12345";
|
||||
const encrypted = encrypt(plaintext);
|
||||
expect(encrypted).not.toBe(plaintext);
|
||||
expect(decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("uses the synthetic 'v1' id when only ENCRYPTION_KEY is set", () => {
|
||||
expect(getActiveKeyId()).toBe("v1");
|
||||
const encrypted = encrypt("hello");
|
||||
expect(encrypted.startsWith("v1.")).toBe(true);
|
||||
expect(extractKeyId(encrypted)).toBe("v1");
|
||||
});
|
||||
|
||||
it("produces different ciphertext each time (random IV)", () => {
|
||||
const plaintext = "same-input";
|
||||
const a = encrypt(plaintext);
|
||||
const b = encrypt(plaintext);
|
||||
const a = encrypt("same-input");
|
||||
const b = encrypt("same-input");
|
||||
expect(a).not.toBe(b);
|
||||
// But both decrypt to the same value
|
||||
expect(decrypt(a)).toBe(plaintext);
|
||||
expect(decrypt(b)).toBe(plaintext);
|
||||
expect(decrypt(a)).toBe("same-input");
|
||||
expect(decrypt(b)).toBe("same-input");
|
||||
});
|
||||
|
||||
it("handles empty strings", () => {
|
||||
const encrypted = encrypt("");
|
||||
expect(decrypt(encrypted)).toBe("");
|
||||
it("handles empty strings, unicode, long input", () => {
|
||||
expect(decrypt(encrypt(""))).toBe("");
|
||||
expect(decrypt(encrypt("Zugangsdaten: 🔐 € ñ"))).toBe(
|
||||
"Zugangsdaten: 🔐 € ñ",
|
||||
);
|
||||
expect(decrypt(encrypt("x".repeat(10000)))).toBe("x".repeat(10000));
|
||||
});
|
||||
|
||||
it("handles unicode characters", () => {
|
||||
const plaintext = "Zugangsdaten: 🔐 Schlüssel € ñ";
|
||||
const encrypted = encrypt(plaintext);
|
||||
expect(decrypt(encrypted)).toBe(plaintext);
|
||||
it("decrypts legacy unversioned ciphertext (bare base64)", () => {
|
||||
// Manually craft a legacy bare-base64 encryption with KEY_V1 by stripping
|
||||
// the v1. prefix the new encoder adds.
|
||||
const versioned = encrypt("legacy-row");
|
||||
const legacy = versioned.slice("v1.".length);
|
||||
// legacy should still decrypt because v1 key is in the keymap
|
||||
expect(decrypt(legacy)).toBe("legacy-row");
|
||||
});
|
||||
|
||||
it("handles long strings", () => {
|
||||
const plaintext = "x".repeat(10000);
|
||||
const encrypted = encrypt(plaintext);
|
||||
expect(decrypt(encrypted)).toBe(plaintext);
|
||||
it("supports ENCRYPTION_KEYS + ENCRYPTION_ACTIVE_KEY_ID multi-key mode", () => {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1, v2: KEY_V2 }));
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2");
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
_resetCryptoCacheForTests();
|
||||
|
||||
expect(getActiveKeyId()).toBe("v2");
|
||||
const ct = encrypt("multi-key-secret");
|
||||
expect(ct.startsWith("v2.")).toBe(true);
|
||||
expect(decrypt(ct)).toBe("multi-key-secret");
|
||||
});
|
||||
|
||||
it("rotation: rows encrypted under old key still decrypt after switching active id", () => {
|
||||
// Phase 1 — write under v1
|
||||
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1 }));
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1");
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
_resetCryptoCacheForTests();
|
||||
const oldRow = encrypt("rotate-me");
|
||||
expect(oldRow.startsWith("v1.")).toBe(true);
|
||||
|
||||
// Phase 2 — add v2 and rotate active to v2; old row still decrypts
|
||||
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1, v2: KEY_V2 }));
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2");
|
||||
_resetCryptoCacheForTests();
|
||||
expect(decrypt(oldRow)).toBe("rotate-me");
|
||||
|
||||
const newRow = reencryptToActive(oldRow);
|
||||
expect(newRow.startsWith("v2.")).toBe(true);
|
||||
expect(decrypt(newRow)).toBe("rotate-me");
|
||||
});
|
||||
|
||||
it("fails to decrypt with wrong key", () => {
|
||||
const encrypted = encrypt("secret");
|
||||
// Change the env key
|
||||
vi.stubEnv(
|
||||
"ENCRYPTION_KEY",
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
);
|
||||
vi.stubEnv("ENCRYPTION_KEY", "f".repeat(64));
|
||||
_resetCryptoCacheForTests();
|
||||
expect(() => decrypt(encrypted)).toThrow();
|
||||
});
|
||||
|
||||
it("fails to decrypt tampered ciphertext", () => {
|
||||
const encrypted = encrypt("secret");
|
||||
const buf = Buffer.from(encrypted, "base64");
|
||||
// Flip a bit in the ciphertext portion
|
||||
// Versioned: "v1.<base64>"
|
||||
const dot = encrypted.indexOf(".");
|
||||
const buf = Buffer.from(encrypted.slice(dot + 1), "base64");
|
||||
buf[buf.length - 1] ^= 0xff;
|
||||
const tampered = buf.toString("base64");
|
||||
const tampered = `v1.${buf.toString("base64")}`;
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it("throws if ENCRYPTION_KEY is missing", () => {
|
||||
it("throws if no encryption configuration is present", () => {
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
expect(() => encrypt("test")).toThrow("ENCRYPTION_KEY");
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
_resetCryptoCacheForTests();
|
||||
expect(() => encrypt("test")).toThrow(/Encryption is not configured/);
|
||||
});
|
||||
|
||||
it("rejects malformed ENCRYPTION_KEYS JSON", () => {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "not-json");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1");
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
_resetCryptoCacheForTests();
|
||||
expect(() => encrypt("test")).toThrow(/valid JSON/);
|
||||
});
|
||||
|
||||
it("rejects an active key id that doesn't exist in the map", () => {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1 }));
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v9");
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
_resetCryptoCacheForTests();
|
||||
expect(() => encrypt("test")).toThrow(/no matching entry/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { CITATIONS, citationLabel, citationUrl } from "../medical-citations";
|
||||
|
||||
describe("medical-citations master list", () => {
|
||||
it("every citation has a non-empty url + caveat", () => {
|
||||
for (const [key, c] of Object.entries(CITATIONS)) {
|
||||
expect(c.id, `${key} id`).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(c.name, `${key} name`).toBeTruthy();
|
||||
expect(c.year, `${key} year`).toBeGreaterThanOrEqual(1900);
|
||||
expect(c.year, `${key} year`).toBeLessThanOrEqual(2100);
|
||||
expect(c.url, `${key} url`).toMatch(/^https?:\/\//);
|
||||
expect(c.caveat, `${key} caveat`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("no two citations share an id", () => {
|
||||
const ids = Object.values(CITATIONS).map((c) => c.id);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("citationUrl resolves to the same string", () => {
|
||||
expect(citationUrl("ESH_2023_HYPERTENSION")).toBe(
|
||||
CITATIONS.ESH_2023_HYPERTENSION.url,
|
||||
);
|
||||
});
|
||||
|
||||
it("citationLabel formats name + year", () => {
|
||||
expect(citationLabel("ESH_2023_HYPERTENSION")).toBe("ESH 2023 (2023)");
|
||||
expect(citationLabel("STEPS_SAINT_MAURICE_2020")).toBe(
|
||||
"Saint-Maurice JAMA 2020 (2020)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects WHO-as-step-source by NOT exposing such a constant", () => {
|
||||
// v3 audit: 'WHO ≥ 8 000 steps/day' is a recurring hallucination.
|
||||
// The codebase must not have a WHO_STEPS-style constant; only the
|
||||
// PA minutes/week guideline (WHO_2020_PA) and the actual cohort
|
||||
// study (STEPS_SAINT_MAURICE_2020) are sanctioned.
|
||||
const keys = Object.keys(CITATIONS);
|
||||
for (const k of keys) {
|
||||
const lower = k.toLowerCase();
|
||||
if (lower.includes("who")) {
|
||||
expect(
|
||||
lower.includes("steps") && !lower.includes("pa"),
|
||||
`${k} cannot link WHO to steps`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
expect(keys).toContain("WHO_2020_PA");
|
||||
expect(keys).toContain("STEPS_SAINT_MAURICE_2020");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user