From d3385478a7796cd6afb6bcc06bc70dcf2a1841c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:28:05 +0200 Subject: [PATCH] feat(settings): switch Apple Health write-back per measurement token Each measurement token row carries a switch, the mint form an opt-in, and the card a notice when readings exist that no token reaches. Copy in all nine locales. The endpoint catalogue moves to its own file and the revoked list out of the token card. --- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/ko.json | 9 +- messages/nl.json | 9 +- messages/pl.json | 9 +- messages/pt-BR.json | 9 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 4 +- .../apple-health-mirror-token.test.tsx | 124 ++++++ .../settings/api-endpoints-card.tsx | 130 +++++++ src/components/settings/api-section.tsx | 367 +++++++----------- .../settings/apple-health-mirror-token.tsx | 138 +++++++ src/lib/query-keys/integrations.ts | 3 + 16 files changed, 613 insertions(+), 238 deletions(-) create mode 100644 src/components/settings/__tests__/apple-health-mirror-token.test.tsx create mode 100644 src/components/settings/api-endpoints-card.tsx create mode 100644 src/components/settings/apple-health-mirror-token.tsx diff --git a/messages/de.json b/messages/de.json index 52c9e2293..2a3d40ac2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6855,7 +6855,14 @@ "detail": "Das Token wird nur einmal angezeigt.", "scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.", "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.", - "namePlaceholder": "Name, z. B. Home Assistant" + "namePlaceholder": "Name, z. B. Home Assistant", + "appleHealth": { + "label": "Messwerte in Apple Health schreiben", + "createHint": "Die HealthLog-App auf deinem iPhone trägt die Werte dieses Tokens dann in Apple Health ein. Du kannst das später in der Token-Liste unten ändern.", + "toggleAria": "Werte von {name} in Apple Health schreiben", + "updateFailed": "Die Apple-Health-Einstellung konnte nicht geändert werden. Bitte versuche es erneut.", + "unattributed": "Werte, die vor dieser Einstellung gesendet wurden, lassen sich keinem Token zuordnen und bleiben deshalb außerhalb von Apple Health." + } }, "workoutsToken": { "title": "Training-Tokens", diff --git a/messages/en.json b/messages/en.json index 00826c3db..97234ceaf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6855,7 +6855,14 @@ "detail": "The token is shown once.", "scopeNote": "It can only add new readings to your own record. It cannot read your data, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later, and you can still correct them yourself.", "createFailed": "The token could not be created. Please try again.", - "namePlaceholder": "Name, e.g. Home Assistant" + "namePlaceholder": "Name, e.g. Home Assistant", + "appleHealth": { + "label": "Write readings to Apple Health", + "createHint": "The HealthLog app on your iPhone then adds this token's readings to Apple Health. You can change this later in the token list below.", + "toggleAria": "Write readings from {name} to Apple Health", + "updateFailed": "The Apple Health setting could not be changed. Please try again.", + "unattributed": "Readings sent before this setting existed can't be matched to a token, so they stay out of Apple Health." + } }, "workoutsToken": { "title": "Workout tokens", diff --git a/messages/es.json b/messages/es.json index ecc6d0e7c..3ca0cf9fa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6855,7 +6855,14 @@ "detail": "El token se muestra una sola vez.", "scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde, y puedes corregirlas igualmente.", "createFailed": "No se pudo crear el token. Inténtalo de nuevo.", - "namePlaceholder": "Nombre, p. ej. Home Assistant" + "namePlaceholder": "Nombre, p. ej. Home Assistant", + "appleHealth": { + "label": "Escribir mediciones en Apple Health", + "createHint": "La app HealthLog de tu iPhone añadirá entonces las mediciones de este token a Apple Health. Puedes cambiarlo más tarde en la lista de tokens de abajo.", + "toggleAria": "Escribir las mediciones de {name} en Apple Health", + "updateFailed": "No se pudo cambiar el ajuste de Apple Health. Inténtalo de nuevo.", + "unattributed": "Las mediciones enviadas antes de que existiera este ajuste no se pueden asociar a ningún token, así que quedan fuera de Apple Health." + } }, "workoutsToken": { "title": "Tokens de entrenamientos", diff --git a/messages/fr.json b/messages/fr.json index 69fbcdfb8..106d1d321 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6855,7 +6855,14 @@ "detail": "Le jeton n'est affiché qu'une fois.", "scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard, et vous pouvez toujours les corriger.", "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.", - "namePlaceholder": "Nom, p. ex. Home Assistant" + "namePlaceholder": "Nom, p. ex. Home Assistant", + "appleHealth": { + "label": "Écrire les mesures dans Apple Health", + "createHint": "L'app HealthLog de votre iPhone ajoutera alors les mesures de ce jeton à Apple Health. Vous pourrez modifier ce choix plus tard dans la liste des jetons ci-dessous.", + "toggleAria": "Écrire les mesures de {name} dans Apple Health", + "updateFailed": "Le réglage Apple Health n'a pas pu être modifié. Veuillez réessayer.", + "unattributed": "Les mesures envoyées avant l'existence de ce réglage ne peuvent être rattachées à aucun jeton : elles restent donc hors d'Apple Health." + } }, "workoutsToken": { "title": "Jetons d'entraînements", diff --git a/messages/it.json b/messages/it.json index 211211b5a..ea4d45364 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6855,7 +6855,14 @@ "detail": "Il token viene mostrato una sola volta.", "scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito, e puoi comunque correggerle.", "createFailed": "Non è stato possibile creare il token. Riprova.", - "namePlaceholder": "Nome, ad es. Home Assistant" + "namePlaceholder": "Nome, ad es. Home Assistant", + "appleHealth": { + "label": "Scrivi le misurazioni in Apple Health", + "createHint": "L'app HealthLog sul tuo iPhone aggiungerà quindi le misurazioni di questo token ad Apple Health. Puoi cambiarlo più tardi nell'elenco dei token qui sotto.", + "toggleAria": "Scrivi le misurazioni di {name} in Apple Health", + "updateFailed": "Non è stato possibile modificare l'impostazione di Apple Health. Riprova.", + "unattributed": "Le misurazioni inviate prima che esistesse questa impostazione non si possono associare a un token, quindi restano fuori da Apple Health." + } }, "workoutsToken": { "title": "Token per allenamenti", diff --git a/messages/ko.json b/messages/ko.json index 5069c1d45..6ec545b8e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6855,7 +6855,14 @@ "detail": "토큰은 한 번만 표시돼요.", "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.", "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.", - "namePlaceholder": "이름 (예: Home Assistant)" + "namePlaceholder": "이름 (예: Home Assistant)", + "appleHealth": { + "label": "측정값을 Apple Health에 기록", + "createHint": "그러면 iPhone의 HealthLog 앱이 이 토큰의 측정값을 Apple Health에 추가해요. 나중에 아래 토큰 목록에서 바꿀 수 있어요.", + "toggleAria": "{name}의 측정값을 Apple Health에 기록", + "updateFailed": "Apple Health 설정을 바꾸지 못했어요. 다시 시도해 주세요.", + "unattributed": "이 설정이 생기기 전에 보낸 측정값은 토큰과 연결할 수 없어서 Apple Health에 기록되지 않아요." + } }, "workoutsToken": { "title": "운동 토큰", diff --git a/messages/nl.json b/messages/nl.json index f091c3c1d..6415f4489 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6855,7 +6855,14 @@ "detail": "Het token wordt één keer getoond.", "scopeNote": "Het kan alleen nieuwe metingen aan je eigen dossier toevoegen. Het kan je gegevens niet lezen, niets wijzigen of verwijderen en geen ander token aanmaken. Metingen worden gemarkeerd als afkomstig van een extern apparaat, zodat je ze later kunt onderscheiden, en je kunt ze nog steeds zelf corrigeren.", "createFailed": "Het token kon niet worden aangemaakt. Probeer het opnieuw.", - "namePlaceholder": "Naam, bijv. Home Assistant" + "namePlaceholder": "Naam, bijv. Home Assistant", + "appleHealth": { + "label": "Metingen naar Apple Health schrijven", + "createHint": "De HealthLog-app op je iPhone zet de metingen van dit token dan in Apple Health. Je kunt dit later wijzigen in de tokenlijst hieronder.", + "toggleAria": "Metingen van {name} naar Apple Health schrijven", + "updateFailed": "De Apple Health-instelling kon niet worden gewijzigd. Probeer het opnieuw.", + "unattributed": "Metingen die zijn verstuurd voordat deze instelling bestond, kunnen niet aan een token worden gekoppeld en blijven daarom buiten Apple Health." + } }, "workoutsToken": { "title": "Trainingstokens", diff --git a/messages/pl.json b/messages/pl.json index 947424424..cb5dc32b7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6855,7 +6855,14 @@ "detail": "Token pokazujemy tylko raz.", "scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji: nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz, i nadal możesz je poprawić.", "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.", - "namePlaceholder": "Nazwa, np. Home Assistant" + "namePlaceholder": "Nazwa, np. Home Assistant", + "appleHealth": { + "label": "Zapisuj pomiary w Apple Health", + "createHint": "Aplikacja HealthLog na Twoim iPhonie doda wtedy pomiary z tego tokenu do Apple Health. Możesz to później zmienić na liście tokenów poniżej.", + "toggleAria": "Zapisuj pomiary z {name} w Apple Health", + "updateFailed": "Nie udało się zmienić ustawienia Apple Health. Spróbuj ponownie.", + "unattributed": "Pomiarów wysłanych, zanim pojawiło się to ustawienie, nie da się przypisać do żadnego tokenu, więc nie trafiają do Apple Health." + } }, "workoutsToken": { "title": "Tokeny treningów", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 277297c97..aa102f900 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -6855,7 +6855,14 @@ "detail": "O token é exibido uma vez.", "scopeNote": "Ele só pode adicionar novas leituras ao seu próprio registro. Não pode ler seus dados, alterar ou excluir nada, nem criar outro token. As leituras são marcadas como vindas de um dispositivo externo, para você distingui-las depois, e você ainda pode corrigi-las você mesmo.", "createFailed": "O token não pôde ser criado. Tente novamente.", - "namePlaceholder": "Nome, ex.: Home Assistant" + "namePlaceholder": "Nome, ex.: Home Assistant", + "appleHealth": { + "label": "Gravar medições no Apple Health", + "createHint": "O app HealthLog no seu iPhone passa a adicionar as medições deste token ao Apple Health. Você pode mudar isso depois na lista de tokens abaixo.", + "toggleAria": "Gravar as medições de {name} no Apple Health", + "updateFailed": "Não foi possível alterar a configuração do Apple Health. Tente novamente.", + "unattributed": "As medições enviadas antes de esta configuração existir não podem ser associadas a um token, por isso ficam fora do Apple Health." + } }, "workoutsToken": { "title": "Tokens de treino", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 4dcbcf91c..8c6ac48ff 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 355, - "total": 114773, + "total": 114676, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -200,7 +200,7 @@ "components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280, "components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228, "components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282, - "components/settings/api-section.tsx :: ApiTokensCard": 366, + "components/settings/api-section.tsx :: ApiTokensCard": 269, "components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251, "components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350, "components/settings/environment-section.tsx :: EnvironmentSection": 666, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 32b18fa49..0e84d0c5f 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2898, - "total": 9297, + "total": 9296, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1359,7 +1359,7 @@ "components/settings/ai/response-timeout-card.tsx": 1, "components/settings/ai/shared.ts": 4, "components/settings/anamnesis-section.tsx": 2, - "components/settings/api-section.tsx": 2, + "components/settings/api-section.tsx": 1, "components/settings/coach-memory-section.tsx": 3, "components/settings/coach-nudge-card.tsx": 4, "components/settings/coach-prefs-section.tsx": 2, diff --git a/src/components/settings/__tests__/apple-health-mirror-token.test.tsx b/src/components/settings/__tests__/apple-health-mirror-token.test.tsx new file mode 100644 index 000000000..c06a93b50 --- /dev/null +++ b/src/components/settings/__tests__/apple-health-mirror-token.test.tsx @@ -0,0 +1,124 @@ +/** + * The Apple Health write-back controls on the API settings (#1272). + * + * SSR-only, as the rest of this directory: the render pins which token rows + * carry the switch and in which state, when the notice about readings that + * belong to no token shows, and the write the switch sends is pinned against + * a stubbed `fetch`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +import { + AppleHealthMirrorOptIn, + AppleHealthMirrorSwitch, + UnattributedReadingsNotice, + patchTokenMirror, +} from "../apple-health-mirror-token"; + +function render(node: React.ReactNode, client = new QueryClient()) { + return renderToStaticMarkup( + + {node} + , + ); +} + +const token = (permissions: string[], mirrorToAppleHealth = false) => ({ + id: "tok-1", + name: "Scale", + permissions, + mirrorToAppleHealth, +}); + +describe("the switch on a token row", () => { + it("appears on a measurement token only", () => { + expect( + render(), + ).toContain('data-slot="apple-health-mirror-switch"'); + for (const permissions of [["*"], ["health:read"], ["workouts:write"]]) { + expect( + render(), + ).toBe(""); + } + }); + + it("shows the stored setting and names the token", () => { + const on = render( + , + ); + expect(on).toContain('aria-checked="true"'); + expect(on).toContain("Write readings from Scale to Apple Health"); + const off = render( + , + ); + expect(off).toContain('aria-checked="false"'); + }); +}); + +describe("the opt-in on the mint form", () => { + it("reads as the setting with its explanation", () => { + const html = render( + {}} />, + ); + expect(html).toContain("Write readings to Apple Health"); + expect(html).toContain("adds this token's readings to Apple Health"); + }); +}); + +describe("the notice about readings no token reaches", () => { + function withSummary(unattributedReadings: number) { + const client = new QueryClient(); + client.setQueryData(queryKeys.measurementTokenSummary(), { + unattributedReadings, + }); + return render(, client); + } + + it("shows only when there are such readings", () => { + expect(withSummary(3)).toContain( + "Readings sent before this setting existed can't be matched to a token", + ); + expect(withSummary(0)).toBe(""); + expect(render()).toBe(""); + }); +}); + +describe("the write", () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: { id: "tok 1", mirrorToAppleHealth: true }, + error: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("patches the token with the one field", async () => { + await expect(patchTokenMirror("tok 1", true)).resolves.toEqual({ + id: "tok 1", + mirrorToAppleHealth: true, + }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/tokens/tok%201"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ + mirrorToAppleHealth: true, + }); + }); +}); diff --git a/src/components/settings/api-endpoints-card.tsx b/src/components/settings/api-endpoints-card.tsx new file mode 100644 index 000000000..4f7889947 --- /dev/null +++ b/src/components/settings/api-endpoints-card.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { Key } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; + +/** The ingest endpoints a token reaches, with the header and a body sketch. */ +export function ApiEndpointsCard() { + const { t } = useTranslations(); + + // Dual-render: `. The endpoint catalogue is array-driven + // so adding a row remains a one-line edit on both surfaces — which is + // what the two measurement ingest rows below cost. + const endpoints = [ + { + method: "POST", + path: "/api/ingest/medication", + auth: "Authorization: Bearer hlk_...", + example: `{ "medicationName": "...", "scheduledFor": "..." }`, + }, + { + method: "POST", + path: "/api/measurements", + auth: "Authorization: Bearer hlk_...", + example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`, + }, + { + method: "POST", + path: "/api/measurements/batch", + auth: "Authorization: Bearer hlk_...", + example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`, + }, + { + method: "POST", + path: "/api/workouts/batch", + auth: "Authorization: Bearer hlk_...", + example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`, + }, + { + method: "POST", + path: "/api/documents/inbound", + auth: "Authorization: Bearer hlk_...", + example: `multipart: file, title, documentDate, sourceSystem, sourceId`, + }, + ]; + + return ( + + + + {/* Desktop table — verbatim layout for md+. */} +
+ + + + + + + + + + + {endpoints.map((endpoint) => ( + + + + + + + ))} + +
+ {t("settings.apiEndpointMethod")} + + {t("settings.apiEndpointPath")} + + {t("settings.apiEndpointAuth")} + + {t("settings.apiEndpointExample")} +
{endpoint.method}{endpoint.path}{endpoint.auth}{endpoint.example}
+
+ + {/* Mobile card list — each endpoint stacks its columns vertically + with a labelled meta row. No horizontal scroll. */} +
    + {endpoints.map((endpoint) => ( +
  • +
    + + {endpoint.method} + + {endpoint.path} +
    +
    +

    + {t("settings.apiEndpointAuth")} +

    + {endpoint.auth} +
    +
    +

    + {t("settings.apiEndpointExample")} +

    + + {endpoint.example} + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 4279624c7..241d1f45f 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -31,6 +31,12 @@ import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { ApiEndpointsCard } from "@/components/settings/api-endpoints-card"; +import { + AppleHealthMirrorOptIn, + AppleHealthMirrorSwitch, + UnattributedReadingsNotice, +} from "@/components/settings/apple-health-mirror-token"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -45,6 +51,7 @@ interface ApiTokenInfo { expiresAt: string | null; createdAt: string; revoked: boolean; + mirrorToAppleHealth: boolean; } export function ApiSection() { @@ -78,6 +85,8 @@ function MeasurementTokensCard() { icon={Upload} slot="settings-measurements-token-created" anchor="measurements-token" + mirrorOptIn + footer={} copy={{ title: t("settings.measurementsToken.title"), description: t("settings.measurementsToken.description"), @@ -147,7 +156,12 @@ function IngestTokenCard({ slot, anchor, copy, + mirrorOptIn = false, + footer, }: { + /** Offer the Apple Health write-back on the mint (measurement tokens). */ + mirrorOptIn?: boolean; + footer?: React.ReactNode; /** Deep-link anchor for the card (`/settings/api#`). */ anchor: string; endpoint: @@ -172,6 +186,7 @@ function IngestTokenCard({ const [creating, setCreating] = useState(false); const [tokenMsg, setTokenMsg] = useState(null); const [tokenCopied, setTokenCopied] = useState(false); + const [mirror, setMirror] = useState(false); // The token outlives the session, so minting one asks for a fresh proof // unless the session signed in or re-proved within five minutes. const recentProof = useRecentProof(); @@ -184,7 +199,10 @@ function IngestTokenCard({ try { // No scope field: the endpoint mints one shape, so there is nothing // here for a caller to choose or for this form to get wrong. - const body = JSON.stringify({ name: newName.trim() }); + const body = JSON.stringify({ + name: newName.trim(), + ...(mirrorOptIn && { mirrorToAppleHealth: mirror }), + }); const res = await recentProof.run(async () => throwIfReproofRequired( await apiFetchRaw(endpoint, { @@ -198,6 +216,7 @@ function IngestTokenCard({ if (res.ok) { setNewToken(json.data.token); setNewName(""); + setMirror(false); // The sibling card lists this token, so it has to hear about it. queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); } else { @@ -263,6 +282,12 @@ function IngestTokenCard({ {t("common.create")} + {mirrorOptIn && ( + + )} {newToken && (
{tokenMsg}

} + {footer}
); } -function ApiEndpointsCard() { - const { t } = useTranslations(); - - // v1.4.27 — dual-render: `. The endpoint catalogue is array-driven - // so adding a row remains a one-line edit on both surfaces — which is - // what the two measurement ingest rows below cost. - const endpoints = [ - { - method: "POST", - path: "/api/ingest/medication", - auth: "Authorization: Bearer hlk_...", - example: `{ "medicationName": "...", "scheduledFor": "..." }`, - }, - { - method: "POST", - path: "/api/measurements", - auth: "Authorization: Bearer hlk_...", - example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`, - }, - { - method: "POST", - path: "/api/measurements/batch", - auth: "Authorization: Bearer hlk_...", - example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`, - }, - { - method: "POST", - path: "/api/workouts/batch", - auth: "Authorization: Bearer hlk_...", - example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`, - }, - { - method: "POST", - path: "/api/documents/inbound", - auth: "Authorization: Bearer hlk_...", - example: `multipart: file, title, documentDate, sourceSystem, sourceId`, - }, - ]; - - return ( - - - - {/* Desktop table — verbatim layout for md+. */} -
- - - - - - - - - - - {endpoints.map((endpoint) => ( - - - - - - - ))} - -
- {t("settings.apiEndpointMethod")} - - {t("settings.apiEndpointPath")} - - {t("settings.apiEndpointAuth")} - - {t("settings.apiEndpointExample")} -
{endpoint.method}{endpoint.path}{endpoint.auth}{endpoint.example}
-
- - {/* Mobile card list — each endpoint stacks its columns vertically - with a labelled meta row. No horizontal scroll. */} -
    - {endpoints.map((endpoint) => ( -
  • -
    - - {endpoint.method} - - {endpoint.path} -
    -
    -

    - {t("settings.apiEndpointAuth")} -

    - {endpoint.auth} -
    -
    -

    - {t("settings.apiEndpointExample")} -

    - - {endpoint.example} - -
    -
  • - ))} -
-
- ); -} - function ApiTokensCard() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); - const [showRevokedTokens, setShowRevokedTokens] = useState(false); const { data: tokens, @@ -557,7 +461,10 @@ function ApiTokensCard() { key={tok.id} className={index % 2 === 0 ? "bg-muted/20" : ""} > - {tok.name} + + {tok.name} + + {tok.permissions.join(", ")} @@ -660,6 +567,7 @@ function ApiTokensCard() { ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

+ {revokedTokens.length > 0 && ( -
- - {showRevokedTokens && ( - <> - {/* Desktop table — verbatim layout for md+. */} -
- - - - - - - - - - - {revokedTokens.map((tok, index) => ( - - - - - - - ))} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} -
- {tok.name} - - {tok.permissions.join(", ")} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -
-
- - {/* Mobile card list — stacked meta layout, no - horizontal scroll. Revoked tokens are read-only so - no action footer is needed. */} -
    - {revokedTokens.map((tok) => ( -
  • -

    - {tok.name} -

    -

    - - {t("settings.tokenTablePermissions")}: - {" "} - {tok.permissions.join(", ")} -

    -

    - - {t("settings.tokenTableCreated")}: - {" "} - {formatDate(tok.createdAt)} -

    -

    - - {t("settings.tokenTableLastUsed")}: - {" "} - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -

    -
  • - ))} -
- - )} -
+ )} )} @@ -787,3 +595,112 @@ function ApiTokensCard() { ); } + +/** The revoked tokens, folded away until asked for. Read-only but for the switch. */ +function RevokedTokens({ tokens: revokedTokens }: { tokens: ApiTokenInfo[] }) { + const { t } = useTranslations(); + const [showRevokedTokens, setShowRevokedTokens] = useState(false); + return ( +
+ + {showRevokedTokens && ( + <> + {/* Desktop table — verbatim layout for md+. */} +
+ + + + + + + + + + + {revokedTokens.map((tok, index) => ( + + + + + + + ))} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} +
+ {tok.name} + + + {tok.permissions.join(", ")} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +
+
+ + {/* Mobile card list — stacked meta layout, no + horizontal scroll. Revoked tokens are read-only so + no action footer is needed. */} +
    + {revokedTokens.map((tok) => ( +
  • +

    {tok.name}

    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {tok.permissions.join(", ")} +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} + {formatDate(tok.createdAt)} +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +

    + +
  • + ))} +
+ + )} +
+ ); +} diff --git a/src/components/settings/apple-health-mirror-token.tsx b/src/components/settings/apple-health-mirror-token.tsx new file mode 100644 index 000000000..843d352bf --- /dev/null +++ b/src/components/settings/apple-health-mirror-token.tsx @@ -0,0 +1,138 @@ +"use client"; + +/** + * A measurement token's one setting (#1272): whether the HealthLog app on the + * iPhone writes the readings that token sent into Apple Health. + * + * The readings keep their EXTERNAL source either way; the server resolves + * the decision per reading and the app follows it. Three pieces, used by the + * API settings: the switch on a measurement token's row, the opt-in on the + * mint form, and the notice about readings that belong to no token. + */ +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** The scope a measurement token carries, and nothing else. */ +const MEASUREMENTS_WRITE = "measurements:write"; + +export function isMeasurementTokenInfo(permissions: readonly string[]) { + return permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE; +} + +/** `PATCH /api/tokens/{id}`: the one write behind the switch. */ +export function patchTokenMirror( + tokenId: string, + mirrorToAppleHealth: boolean, +) { + return apiPatch<{ id: string; mirrorToAppleHealth: boolean }>( + `/api/tokens/${encodeURIComponent(tokenId)}`, + { mirrorToAppleHealth }, + ); +} + +/** The switch on a measurement token's row; nothing on any other token. */ +export function AppleHealthMirrorSwitch({ + token, +}: { + token: { + id: string; + name: string; + permissions: string[]; + mirrorToAppleHealth: boolean; + }; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [saving, setSaving] = useState(false); + if (!isMeasurementTokenInfo(token.permissions)) return null; + + async function handleChange(next: boolean) { + setSaving(true); + try { + await patchTokenMirror(token.id, next); + await queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); + } catch (failure) { + showErrorToast(t("settings.measurementsToken.appleHealth.updateFailed"), { + cause: failure, + }); + } finally { + setSaving(false); + } + } + + return ( + + ); +} + +/** The opt-in on the measurement mint form. Off unless the person ticks it. */ +export function AppleHealthMirrorOptIn({ + checked, + onCheckedChange, +}: { + checked: boolean; + onCheckedChange: (next: boolean) => void; +}) { + const { t } = useTranslations(); + return ( + + ); +} + +/** + * Shown only when the account holds EXTERNAL readings no token reaches: sent + * before readings were linked to their token, by an account that held more + * than one, so no setting here can put them into Apple Health. + */ +export function UnattributedReadingsNotice() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const { data } = useQuery({ + queryKey: queryKeys.measurementTokenSummary(), + queryFn: () => + apiGet<{ unattributedReadings: number }>("/api/tokens/measurements"), + enabled: isAuthenticated, + }); + if (!data || !(data.unattributedReadings > 0)) return null; + return ( +

+ {t("settings.measurementsToken.appleHealth.unattributed")} +

+ ); +} diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 11c0e3b90..732617e1f 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -5,6 +5,9 @@ */ export const integrationKeys = { tokens: () => ["tokens"] as const, + // How many EXTERNAL readings belong to no measurement token (#1272). Under + // the `["tokens"]` prefix, so a mint or a revoke refreshes it too. + measurementTokenSummary: () => ["tokens", "measurements", "summary"] as const, // v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted // for the remote MCP endpoint). Distinct key from `tokens()` so the MCP // settings card's mint / revoke mutations invalidate only this list.