mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-tablet
This commit is contained in:
commit
803ef73a4d
97 files changed
+4035
-461
No files matched your search
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user