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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 19:28:05 +02:00
1 parent d6774b770d
commit d3385478a7
16 files changed
+613 -238

No files matched your search

+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "운동 토큰",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
@@ -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,
@@ -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,
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
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(<AppleHealthMirrorSwitch token={token(["measurements:write"])} />),
).toContain('data-slot="apple-health-mirror-switch"');
for (const permissions of [["*"], ["health:read"], ["workouts:write"]]) {
expect(
render(<AppleHealthMirrorSwitch token={token(permissions)} />),
).toBe("");
}
});
it("shows the stored setting and names the token", () => {
const on = render(
<AppleHealthMirrorSwitch token={token(["measurements:write"], true)} />,
);
expect(on).toContain('aria-checked="true"');
expect(on).toContain("Write readings from Scale to Apple Health");
const off = render(
<AppleHealthMirrorSwitch token={token(["measurements:write"])} />,
);
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(
<AppleHealthMirrorOptIn checked={false} onCheckedChange={() => {}} />,
);
expect(html).toContain("Write readings to Apple Health");
expect(html).toContain("adds this token&#x27;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(<UnattributedReadingsNotice />, client);
}
it("shows only when there are such readings", () => {
expect(withSummary(3)).toContain(
"Readings sent before this setting existed can&#x27;t be matched to a token",
);
expect(withSummary(0)).toBe("");
expect(render(<UnattributedReadingsNotice />)).toBe("");
});
});
describe("the write", () => {
let fetchMock: ReturnType<typeof vi.fn>;
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,
});
});
});
@@ -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: <md gets a stacked card list, md+
// keeps the original table. Mobile cards wrap inside their box and
// never trigger horizontal scroll, mirroring the pattern from
// `<ApiTokenOverviewSection>`. 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 (
<SettingsCard>
<SettingsCardHeader
anchor="api-endpoints"
icon={Key}
title={t("settings.apiEndpointsTitle")}
description={t("settings.apiEndpointsDescription")}
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<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>
{endpoints.map((endpoint) => (
<tr key={`${endpoint.method} ${endpoint.path}`}>
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
<li
key={`${endpoint.method} ${endpoint.path}`}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
{endpoint.example}
</code>
</div>
</li>
))}
</ul>
</SettingsCard>
);
}
+142 -225
View File
@@ -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={<UnattributedReadingsNotice />}
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>`). */
anchor: string;
endpoint:
@@ -172,6 +186,7 @@ function IngestTokenCard({
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(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")}
</Button>
</form>
{mirrorOptIn && (
<AppleHealthMirrorOptIn
checked={mirror}
onCheckedChange={setMirror}
/>
)}
{newToken && (
<div
@@ -295,137 +320,16 @@ function IngestTokenCard({
)}
{tokenMsg && <p className="text-destructive text-sm">{tokenMsg}</p>}
{footer}
</div>
</SettingsCard>
);
}
function ApiEndpointsCard() {
const { t } = useTranslations();
// v1.4.27 — dual-render: <md gets a stacked card list, md+
// keeps the original table. Mobile cards wrap inside their box and
// never trigger horizontal scroll, mirroring the pattern from
// `<ApiTokenOverviewSection>`. 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 (
<SettingsCard>
<SettingsCardHeader
anchor="api-endpoints"
icon={Key}
title={t("settings.apiEndpointsTitle")}
description={t("settings.apiEndpointsDescription")}
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<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>
{endpoints.map((endpoint) => (
<tr key={`${endpoint.method} ${endpoint.path}`}>
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
<li
key={`${endpoint.method} ${endpoint.path}`}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
{endpoint.example}
</code>
</div>
</li>
))}
</ul>
</SettingsCard>
);
}
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" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="px-3 py-2 font-medium">
{tok.name}
<AppleHealthMirrorSwitch token={tok} />
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
@@ -660,6 +567,7 @@ function ApiTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AppleHealthMirrorSwitch token={tok} />
<ConfirmButton
label={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
@@ -679,107 +587,7 @@ function ApiTokensCard() {
</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 && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<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>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">
{tok.name}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
</li>
))}
</ul>
</>
)}
</div>
<RevokedTokens tokens={revokedTokens} />
)}
</>
)}
@@ -787,3 +595,112 @@ function ApiTokensCard() {
</SettingsCard>
);
}
/** 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 (
<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 && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<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}
<AppleHealthMirrorSwitch token={tok} />
</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>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">{tok.name}</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AppleHealthMirrorSwitch token={tok} />
</li>
))}
</ul>
</>
)}
</div>
);
}
@@ -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 (
<label
className="flex min-h-11 items-center gap-2 sm:min-h-9"
data-slot="apple-health-mirror-switch"
>
<Switch
checked={token.mirrorToAppleHealth}
onCheckedChange={(next) => void handleChange(next)}
disabled={saving}
aria-label={t("settings.measurementsToken.appleHealth.toggleAria", {
name: token.name,
})}
/>
<span className="text-xs">
{t("settings.measurementsToken.appleHealth.label")}
</span>
</label>
);
}
/** 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 (
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-slot="apple-health-mirror-opt-in"
checked={checked}
onCheckedChange={(next) => onCheckedChange(next === true)}
className="mt-0.5"
/>
<span>
{t("settings.measurementsToken.appleHealth.label")}
<span className="text-muted-foreground block text-xs">
{t("settings.measurementsToken.appleHealth.createHint")}
</span>
</span>
</label>
);
}
/**
* 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 (
<p className="text-sm" data-slot="apple-health-unattributed-notice">
{t("settings.measurementsToken.appleHealth.unattributed")}
</p>
);
}
+3
View File
@@ -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.