mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(integrations): let a guardian connect a managed profile's providers
A guardian acting on a managed profile can now connect and disconnect its Withings, WHOOP, Oura, Polar, Strava and Google Health from the profile's integration cards, and each card says whose record it connects for. The connect starts with a fenced fetch that mints a state naming the profile; the provider's callback lands tokens and data there only after checking that the browser belongs to a guardian with a live MANAGE grant on it. Without a provider app of its own, the profile borrows the guardian's, with an audit row. Withings and Google Health callbacks read the session directly, since a provider redirect cannot carry the record-session header.
This commit is contained in:
1 parent
65e0cba0bf
commit
74ce17b7de
35 files changed
+1392
-158
No files matched your search
@@ -672,6 +672,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/managed-integration-status.tsx",
|
||||
destroys:
|
||||
"a managed profile's stored Withings, WHOOP, Google Health, Polar, Oura or Strava grant, disconnected by its guardian — reconnecting means authorising again",
|
||||
triggers: ["components/settings/integrations/integration-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/nightscout-card.tsx",
|
||||
destroys:
|
||||
|
||||
@@ -20,9 +20,11 @@ describe("record settings route guard", () => {
|
||||
.map((entry) => `app/api/record-settings/${entry}`)
|
||||
.sort();
|
||||
|
||||
expect(discovered).toHaveLength(3);
|
||||
expect(discovered).toHaveLength(5);
|
||||
expect(discovered).toEqual([
|
||||
"app/api/record-settings/[family]/route.ts",
|
||||
"app/api/record-settings/integrations/[provider]/connect/route.ts",
|
||||
"app/api/record-settings/integrations/[provider]/disconnect/route.ts",
|
||||
"app/api/record-settings/integrations/route.ts",
|
||||
"app/api/record-settings/route.ts",
|
||||
]);
|
||||
@@ -32,6 +34,8 @@ describe("record settings route guard", () => {
|
||||
const routes = [
|
||||
"route.ts",
|
||||
"integrations/route.ts",
|
||||
"integrations/[provider]/connect/route.ts",
|
||||
"integrations/[provider]/disconnect/route.ts",
|
||||
"[family]/route.ts",
|
||||
] as const;
|
||||
const sources = await Promise.all(
|
||||
|
||||
@@ -27,7 +27,7 @@ function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
|
||||
}
|
||||
|
||||
describe("managed record settings UI", () => {
|
||||
it("gates shared deep links and renders integration status without controls", async () => {
|
||||
it("gates shared deep links and controls integrations only through the managed-record routes", async () => {
|
||||
const [
|
||||
statusView,
|
||||
managedSettingsView,
|
||||
@@ -58,8 +58,17 @@ describe("managed record settings UI", () => {
|
||||
expect(statusView).toContain("recordSettingsIntegrations");
|
||||
expect(statusView).toContain("/api/record-settings/integrations");
|
||||
expect(statusView).toContain("assertRecordSettingsResponseForRecord");
|
||||
expect(statusView).not.toContain("<Button");
|
||||
expect(statusView).not.toContain("<Link");
|
||||
// A guardian's controls go through the fenced managed-record routes, never
|
||||
// the owner's provider routes and never a navigation to the API.
|
||||
expect(statusView).toContain(
|
||||
"/api/record-settings/integrations/${provider}/connect",
|
||||
);
|
||||
expect(statusView).toContain(
|
||||
"/api/record-settings/integrations/${provider}/disconnect",
|
||||
);
|
||||
expect(statusView).not.toMatch(/["`]\/api\/\$\{provider\}\//);
|
||||
expect(statusView).not.toMatch(/loadDocument\(\s*["`]\/api/);
|
||||
expect(statusView).not.toMatch(/<Link[\s>]/);
|
||||
expect(settingsPage).toContain("RecordSettingsSectionGate");
|
||||
expect(sectionGate).toContain("if (!inSharedRecord) return children");
|
||||
expect(sectionGate).toContain("ManagedIntegrationStatus");
|
||||
|
||||
@@ -115,6 +115,14 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
"consent decision on a route outside apiHandler",
|
||||
// Provider OAuth callbacks. The browser arrives as a top-level
|
||||
// navigation, so the code is bound to the session user and redirected.
|
||||
// v1.44 — Withings and Google Health join: a navigation cannot carry the
|
||||
// record-session header a record resolver needs, and the session user may
|
||||
// be a guardian finishing a managed profile's connect, which
|
||||
// `verifyCallbackActor` checks against a live MANAGE grant.
|
||||
"app/api/google-health/callback/route.ts":
|
||||
"binds the OAuth code to the session user",
|
||||
"app/api/withings/callback/route.ts":
|
||||
"binds the OAuth code to the session user",
|
||||
"app/api/oura/callback/route.ts":
|
||||
"binds the OAuth code to the session user",
|
||||
"app/api/polar/callback/route.ts":
|
||||
|
||||
@@ -2044,6 +2044,10 @@ const GUARDIAN_ROUTES: Record<string, string> = {
|
||||
"Verified integration connection status for the managed record, without any provider control surface.",
|
||||
"app/api/record-settings/[family]/route.ts":
|
||||
"The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.",
|
||||
"app/api/record-settings/integrations/[provider]/connect/route.ts":
|
||||
"Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.",
|
||||
"app/api/record-settings/integrations/[provider]/disconnect/route.ts":
|
||||
"Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2164,8 +2168,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* v1.43 -- AUDIT-C's create and soft delete join the record list and the
|
||||
* manage literal under `profile`, the level allergies are written at.
|
||||
* 262 -> 266.
|
||||
*
|
||||
* v1.44 -- a guardian connects and disconnects a managed profile's OAuth
|
||||
* providers: two guardian-only entries. 266 -> 268.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 266;
|
||||
const FROZEN_ENTRY_COUNT = 268;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
Reference in new issue
Block a user