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

@@ -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.