From be94e434800519771f8b08c716538099726e44b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 01:33:06 +0200 Subject: [PATCH] feat(share-links): owner share-link management in Settings Add a Sharing section under Settings (between API and Export) where the owner creates a read-only clinician share link, sees their active and inactive links, and revokes one. Create wires to the C4 lifecycle API: it sends the label, a rolling history window, an expiry (capped at 90 days, validated before the round trip), an optional scoped FHIR-API toggle, and the FHIR resource types. The raw token is revealed exactly once on create as a copy-able `/c/` URL; the list only ever carries the server's safe projection (no token, since only its hash is stored). Revoke confirms through an alert dialog and invalidates the list. RSC page mounts a client island; reads unwrap the envelope and key off the centralised query factory. New `settings.sharing` / `settings.sections.sharing` strings land in all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 36 +- messages/en.json | 36 +- messages/es.json | 36 +- messages/fr.json | 36 +- messages/it.json | 36 +- messages/pl.json | 36 +- src/app/settings/[section]/page.tsx | 2 + .../__tests__/settings-shell.test.tsx | 7 +- .../__tests__/sharing-section.test.tsx | 125 +++++ src/components/settings/section-slugs.ts | 4 + src/components/settings/settings-shell.tsx | 6 + src/components/settings/sharing-section.tsx | 508 ++++++++++++++++++ src/lib/query-keys.ts | 4 + 13 files changed, 864 insertions(+), 8 deletions(-) create mode 100644 src/components/settings/__tests__/sharing-section.test.tsx create mode 100644 src/components/settings/sharing-section.tsx diff --git a/messages/de.json b/messages/de.json index 652dd2153..7975bedc1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3122,6 +3122,10 @@ "about": { "title": "Über", "description": "Version, Lizenz, Links." + }, + "sharing": { + "title": "Freigabe", + "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen." } }, "profile": "Profil", @@ -3556,7 +3560,37 @@ "error": "Export fehlgeschlagen ({code})." }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", - "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt." + "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", + "sharing": { + "createTitle": "Neuer Freigabe-Link", + "createDescription": "Der Link ist schreibgeschützt und zeitlich begrenzt. Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen.", + "label": "Bezeichnung", + "labelPlaceholder": "z. B. Dr. Schmidt — Jahres-Check-up", + "range": "Tage Verlauf", + "rangeHint": "Wie weit das geteilte Zeitfenster zurückreicht.", + "expiry": "Läuft ab in (Tagen)", + "expiryHint": "Höchstens {max} Tage.", + "expiryInvalid": "Die Gültigkeit muss zwischen 1 und {max} Tagen liegen.", + "fhirApi": "Eingegrenzte FHIR-API erlauben", + "fhirApiHint": "Lasse den Link zusätzlich zur Seitenansicht einen schreibgeschützten FHIR-Endpunkt bereitstellen.", + "resourceTypes": "FHIR-Ressourcentypen", + "create": "Link erstellen", + "tokenCreated": "Freigabe-Link erstellt", + "tokenOnce": "Kopiere diesen Link jetzt — der Token wird nur einmal angezeigt und lässt sich nicht wiederherstellen.", + "copy": "Link kopieren", + "copied": "In die Zwischenablage kopiert.", + "activeTitle": "Aktive Links", + "noActive": "Keine aktiven Freigabe-Links.", + "created": "Erstellt", + "expires": "Läuft ab", + "accessCount": "Aufrufe", + "revoke": "Widerrufen", + "revokeDescription": "Diesen Link sofort widerrufen. Wer ihn besitzt, verliert umgehend den Zugriff. Das lässt sich nicht rückgängig machen.", + "statusActive": "Aktiv", + "statusRevoked": "Widerrufen", + "statusExpired": "Abgelaufen", + "inactiveTitle": "Widerrufen und abgelaufen ({count})" + } }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index aecdfcbae..ad4fa6bc3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3122,6 +3122,10 @@ "about": { "title": "About", "description": "Version, license, links." + }, + "sharing": { + "title": "Sharing", + "description": "Create a read-only link to share your health record with a clinician." } }, "profile": "Profile", @@ -3556,7 +3560,37 @@ "error": "Export failed ({code})." }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", - "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them." + "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", + "sharing": { + "createTitle": "New share link", + "createDescription": "The link is read-only and time-boxed. The token is shown once on creation — store it then; it cannot be recovered.", + "label": "Label", + "labelPlaceholder": "e.g. Dr. Smith — annual check-up", + "range": "Days of history", + "rangeHint": "How far back the shared window reaches.", + "expiry": "Expires in (days)", + "expiryHint": "At most {max} days.", + "expiryInvalid": "Expiry must be between 1 and {max} days.", + "fhirApi": "Allow scoped FHIR API", + "fhirApiHint": "Let the link serve a read-only FHIR endpoint in addition to the page view.", + "resourceTypes": "FHIR resource types", + "create": "Create link", + "tokenCreated": "Share link created", + "tokenOnce": "Copy this link now — the token is shown only once and cannot be recovered.", + "copy": "Copy link", + "copied": "Copied to clipboard.", + "activeTitle": "Active links", + "noActive": "No active share links.", + "created": "Created", + "expires": "Expires", + "accessCount": "Views", + "revoke": "Revoke", + "revokeDescription": "Revoke this link immediately. Anyone holding it loses access at once. This cannot be undone.", + "statusActive": "Active", + "statusRevoked": "Revoked", + "statusExpired": "Expired", + "inactiveTitle": "Revoked and expired ({count})" + } }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index 44af26e15..e6c610842 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3122,6 +3122,10 @@ "about": { "title": "About", "description": "Version, license, links." + }, + "sharing": { + "title": "Compartir", + "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico." } }, "profile": "Perfil", @@ -3556,7 +3560,37 @@ "error": "Error en la exportación ({code})." }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", - "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera." + "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", + "sharing": { + "createTitle": "Nuevo enlace para compartir", + "createDescription": "El enlace es de solo lectura y limitado en el tiempo. El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar.", + "label": "Etiqueta", + "labelPlaceholder": "p. ej. Dra. García — revisión anual", + "range": "Días de historial", + "rangeHint": "Hasta cuándo se remonta la ventana compartida.", + "expiry": "Caduca en (días)", + "expiryHint": "Como máximo {max} días.", + "expiryInvalid": "La caducidad debe estar entre 1 y {max} días.", + "fhirApi": "Permitir API FHIR acotada", + "fhirApiHint": "Deja que el enlace sirva un endpoint FHIR de solo lectura además de la vista de página.", + "resourceTypes": "Tipos de recurso FHIR", + "create": "Crear enlace", + "tokenCreated": "Enlace para compartir creado", + "tokenOnce": "Copia este enlace ahora — el token se muestra una sola vez y no se puede recuperar.", + "copy": "Copiar enlace", + "copied": "Copiado al portapapeles.", + "activeTitle": "Enlaces activos", + "noActive": "No hay enlaces para compartir activos.", + "created": "Creado", + "expires": "Caduca", + "accessCount": "Vistas", + "revoke": "Revocar", + "revokeDescription": "Revoca este enlace de inmediato. Quien lo tenga pierde el acceso al instante. Esto no se puede deshacer.", + "statusActive": "Activo", + "statusRevoked": "Revocado", + "statusExpired": "Caducado", + "inactiveTitle": "Revocados y caducados ({count})" + } }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index 8a07a68c9..9a9197ad4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3122,6 +3122,10 @@ "about": { "title": "About", "description": "Version, license, links." + }, + "sharing": { + "title": "Partage", + "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé." } }, "profile": "Profil", @@ -3556,7 +3560,37 @@ "error": "Échec de l’export ({code})." }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", - "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie." + "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", + "sharing": { + "createTitle": "Nouveau lien de partage", + "createDescription": "Le lien est en lecture seule et limité dans le temps. Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré.", + "label": "Libellé", + "labelPlaceholder": "ex. Dr Martin — bilan annuel", + "range": "Jours d'historique", + "rangeHint": "Jusqu'où remonte la fenêtre partagée.", + "expiry": "Expire dans (jours)", + "expiryHint": "Au plus {max} jours.", + "expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.", + "fhirApi": "Autoriser l'API FHIR restreinte", + "fhirApiHint": "Laissez le lien servir un point d'accès FHIR en lecture seule en plus de la vue page.", + "resourceTypes": "Types de ressource FHIR", + "create": "Créer le lien", + "tokenCreated": "Lien de partage créé", + "tokenOnce": "Copiez ce lien maintenant — le jeton n'est affiché qu'une fois et ne peut pas être récupéré.", + "copy": "Copier le lien", + "copied": "Copié dans le presse-papiers.", + "activeTitle": "Liens actifs", + "noActive": "Aucun lien de partage actif.", + "created": "Créé", + "expires": "Expire", + "accessCount": "Vues", + "revoke": "Révoquer", + "revokeDescription": "Révoquez ce lien immédiatement. Quiconque le détient perd l'accès aussitôt. Action irréversible.", + "statusActive": "Actif", + "statusRevoked": "Révoqué", + "statusExpired": "Expiré", + "inactiveTitle": "Révoqués et expirés ({count})" + } }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index e25ad9ad5..b1914e763 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3122,6 +3122,10 @@ "about": { "title": "About", "description": "Version, license, links." + }, + "sharing": { + "title": "Condivisione", + "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico." } }, "profile": "Profilo", @@ -3556,7 +3560,37 @@ "error": "Esportazione non riuscita ({code})." }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", - "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce." + "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", + "sharing": { + "createTitle": "Nuovo link di condivisione", + "createDescription": "Il link è di sola lettura e a tempo. Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato.", + "label": "Etichetta", + "labelPlaceholder": "es. Dott. Rossi — controllo annuale", + "range": "Giorni di cronologia", + "rangeHint": "Fin dove si estende la finestra condivisa.", + "expiry": "Scade tra (giorni)", + "expiryHint": "Al massimo {max} giorni.", + "expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.", + "fhirApi": "Consenti API FHIR limitata", + "fhirApiHint": "Lascia che il link offra un endpoint FHIR di sola lettura oltre alla vista della pagina.", + "resourceTypes": "Tipi di risorsa FHIR", + "create": "Crea link", + "tokenCreated": "Link di condivisione creato", + "tokenOnce": "Copia ora questo link — il token viene mostrato una sola volta e non può essere recuperato.", + "copy": "Copia link", + "copied": "Copiato negli appunti.", + "activeTitle": "Link attivi", + "noActive": "Nessun link di condivisione attivo.", + "created": "Creato", + "expires": "Scade", + "accessCount": "Visualizzazioni", + "revoke": "Revoca", + "revokeDescription": "Revoca subito questo link. Chiunque lo possieda perde immediatamente l'accesso. L'operazione è irreversibile.", + "statusActive": "Attivo", + "statusRevoked": "Revocato", + "statusExpired": "Scaduto", + "inactiveTitle": "Revocati e scaduti ({count})" + } }, "admin": { "title": "Administration", diff --git a/messages/pl.json b/messages/pl.json index 461326124..0f6cdac16 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3122,6 +3122,10 @@ "about": { "title": "About", "description": "Version, license, links." + }, + "sharing": { + "title": "Udostępnianie", + "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi." } }, "profile": "Profil", @@ -3556,7 +3560,37 @@ "error": "Eksport nie powiódł się ({code})." }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", - "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje." + "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", + "sharing": { + "createTitle": "Nowy link udostępniania", + "createDescription": "Link jest tylko do odczytu i ograniczony czasowo. Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać.", + "label": "Etykieta", + "labelPlaceholder": "np. dr Kowalski — kontrola roczna", + "range": "Dni historii", + "rangeHint": "Jak daleko wstecz sięga udostępnione okno.", + "expiry": "Wygasa za (dni)", + "expiryHint": "Najwyżej {max} dni.", + "expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.", + "fhirApi": "Zezwól na ograniczone API FHIR", + "fhirApiHint": "Pozwól, aby link udostępniał punkt końcowy FHIR tylko do odczytu obok widoku strony.", + "resourceTypes": "Typy zasobów FHIR", + "create": "Utwórz link", + "tokenCreated": "Link udostępniania utworzony", + "tokenOnce": "Skopiuj ten link teraz — token jest pokazywany tylko raz i nie można go odzyskać.", + "copy": "Kopiuj link", + "copied": "Skopiowano do schowka.", + "activeTitle": "Aktywne linki", + "noActive": "Brak aktywnych linków udostępniania.", + "created": "Utworzono", + "expires": "Wygasa", + "accessCount": "Wyświetlenia", + "revoke": "Unieważnij", + "revokeDescription": "Unieważnij ten link natychmiast. Każdy, kto go ma, traci dostęp od razu. Tej operacji nie można cofnąć.", + "statusActive": "Aktywny", + "statusRevoked": "Unieważniony", + "statusExpired": "Wygasły", + "inactiveTitle": "Unieważnione i wygasłe ({count})" + } }, "admin": { "title": "Administration", diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 38e5fadae..1210e2074 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -11,6 +11,7 @@ import { ExportSection } from "@/components/settings/export-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; +import { SharingSection } from "@/components/settings/sharing-section"; // v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two // separate sections again. `ThresholdsSection` renders the target-range // editor; `SourcesSection` (standalone mode) renders the source-priority @@ -52,6 +53,7 @@ const SECTION_COMPONENTS: Record< thresholds: ThresholdsSection, sources: () => , api: ApiSection, + sharing: SharingSection, export: ExportSection, advanced: AdvancedSection, }; diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 829205233..f1f43102e 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -35,7 +35,7 @@ function renderShell(props: { } describe("SETTINGS_SECTION_SLUGS", () => { - it("declares the eleven sections", () => { + it("declares the twelve sections", () => { // Order matters — `generateStaticParams()` and the sidebar derive their // ordering from this constant, so a reorder is a behaviour change. // v1.4.3 split the dashboard panel: layout stays under `dashboard`, @@ -47,7 +47,9 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.4.25 W5e added `sources` between `thresholds` and `ai`; v1.4.34 // IW-D merged it into `thresholds`. // v1.8.7.1 — `sources` (Sources) is its own slug again, sitting - // between `thresholds` (Targets) and `ai`. Section count: 11. + // between `thresholds` (Targets) and `ai`. + // v1.11.0 — `sharing` (clinician share links) added between `api` and + // `export`. Section count: 12. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "integrations", @@ -57,6 +59,7 @@ describe("SETTINGS_SECTION_SLUGS", () => { "sources", "ai", "api", + "sharing", "export", "advanced", "about", diff --git a/src/components/settings/__tests__/sharing-section.test.tsx b/src/components/settings/__tests__/sharing-section.test.tsx new file mode 100644 index 000000000..f09fa06e4 --- /dev/null +++ b/src/components/settings/__tests__/sharing-section.test.tsx @@ -0,0 +1,125 @@ +/** + * v1.11.0 — Settings → Sharing (Epic C, C7). + * + * The owner share-link surface. The security-load-bearing guarantees the + * test pins: + * 1. The list never carries a raw `hls_` token — the server stores only the + * hash, so the list response shape has no token field and the rendered + * markup must not surface one. + * 2. The one-time token reveal card (`share-token-reveal`) is absent until a + * create succeeds — on first paint (no token in state) it must not render. + * 3. Active links render with a revoke control; revoked/expired links fold + * into the inactive list. + * 4. The expiry input is bounded at the server cap (90 days). + * + * SSR-static render only (no jsdom), matching the sibling settings-section + * tests. The TanStack hooks are mocked so the query returns a fixed link list. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +const FIXTURE_LINKS = [ + { + id: "link-active", + label: "Cardiology referral", + rangeStart: "2026-01-01T00:00:00.000Z", + rangeEnd: null, + resourceTypes: ["Patient", "Observation"], + allowFhirApi: true, + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: "2026-06-01T00:00:00.000Z", + revokedAt: null, + lastAccessAt: "2026-06-02T10:00:00.000Z", + accessCount: 3, + active: true, + }, + { + id: "link-revoked", + label: "Old GP link", + rangeStart: "2026-01-01T00:00:00.000Z", + rangeEnd: null, + resourceTypes: [], + allowFhirApi: false, + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: "2026-05-01T00:00:00.000Z", + revokedAt: "2026-05-15T00:00:00.000Z", + lastAccessAt: null, + accessCount: 0, + active: false, + }, +]; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: FIXTURE_LINKS, isLoading: false }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SharingSection } from "../sharing-section"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — owner share-link surface (C7)", () => { + it("renders the create form with a 90-day-capped expiry input", () => { + const html = render(); + expect(html).toContain("New share link"); + // The expiry field carries the server's max-days cap. + expect(html).toContain('max="90"'); + expect(html).toContain("Create link"); + }); + + it("does not reveal a token before a create succeeds", () => { + const html = render(); + // The one-time reveal card only mounts once `newToken` state is set. + expect(html).not.toContain('data-testid="share-token-reveal"'); + }); + + it("lists the active link with a revoke control and never a raw token", () => { + const html = render(); + expect(html).toContain('data-testid="share-active-list"'); + expect(html).toContain("Cardiology referral"); + expect(html).toContain("Revoke"); + // The list response shape carries no token; the markup must not surface + // an `hls_` string anywhere. + expect(html).not.toContain("hls_"); + // The FHIR badge surfaces for the FHIR-enabled link. + expect(html).toContain("FHIR"); + }); + + it("folds revoked links into the inactive list (not the active list)", () => { + const html = render(); + // The active list must NOT contain the revoked link's label. + const activeListStart = html.indexOf('data-testid="share-active-list"'); + const activeListEnd = html.indexOf('data-testid="share-inactive-list"'); + // Inactive list is collapsed by default, so the label only appears once + // the disclosure is open — but the active list must never carry it. + const activeSlice = + activeListEnd > activeListStart + ? html.slice(activeListStart, activeListEnd) + : html.slice(activeListStart); + expect(activeSlice).not.toContain("Old GP link"); + // The inactive-count disclosure surfaces. + expect(html).toContain("Revoked and expired"); + }); + + it("renders localized section copy", () => { + const html = render("de"); + expect(html).toContain("Neuer Freigabe-Link"); + }); +}); diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 76f010ef5..2b83ff968 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -22,6 +22,10 @@ export const SETTINGS_SECTION_SLUGS = [ "sources", "ai", "api", + // v1.11.0 — `sharing` owns clinician share links: a time-boxed, scope- + // frozen read-only view of the owner's record, optionally a scoped FHIR + // face. Sits next to `api` (the other "hand a credential out" surface). + "sharing", "export", "advanced", "about", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 22043e34a..954475e1f 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -25,6 +25,7 @@ import { LayoutDashboard, Link2, Settings2, + Share2, SlidersHorizontal, Sparkles, User, @@ -109,6 +110,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ }, { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, + { + slug: "sharing", + titleKey: "settings.sections.sharing.title", + icon: Share2, + }, { slug: "export", titleKey: "settings.sections.export.title", diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx new file mode 100644 index 000000000..85fb2a05f --- /dev/null +++ b/src/components/settings/sharing-section.tsx @@ -0,0 +1,508 @@ +"use client"; + +/** + * v1.11.0 — Settings → Sharing (Epic C, C7). + * + * The OWNER surface for clinician share links. A share link is a time-boxed, + * scope-frozen, read-only view of the owner's own health record at + * `/c/`, optionally exposing a scoped read-only FHIR face. The raw + * `hls_` token is shown EXACTLY ONCE on create — the list never carries it + * (the server only stores its hash), so the copy-on-create card is the single + * chance to capture it. + * + * Client island: the create form, the active/revoked lists, and the revoke + * action all need state and mutate via the C4 lifecycle API + * (`/api/share-links`). Reads unwrap `(await res.json()).data`; the query key + * comes from the centralised factory. No markdown anywhere — every value + * renders as escaped React text. + */ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Copy, Loader2, Share2, Trash2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useAuth } from "@/hooks/use-auth"; +import { formatDate, formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** The FHIR resource types a share link may serve — mirrors C4's enum. */ +const RESOURCE_TYPES = [ + "Patient", + "Observation", + "MedicationStatement", + "MedicationAdministration", +] as const; +type ResourceType = (typeof RESOURCE_TYPES)[number]; + +/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */ +const MAX_DAYS = 90; +const DEFAULT_DAYS = 30; + +/** Owner-facing shape returned by `GET /api/share-links` (never the token). */ +interface ShareLinkSummary { + id: string; + label: string; + rangeStart: string; + rangeEnd: string | null; + resourceTypes: string[]; + allowFhirApi: boolean; + expiresAt: string; + createdAt: string; + revokedAt: string | null; + lastAccessAt: string | null; + accessCount: number; + active: boolean; +} + +export function SharingSection() { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("settings.sections.sharing.title")} +

+

+ {t("settings.sections.sharing.description")} +

+
+ + +
+ ); +} + +function isoDaysFromNow(days: number): string { + return new Date(Date.now() + days * 86_400_000).toISOString(); +} + +function ShareLinksCard() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const [label, setLabel] = useState(""); + const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS); + const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS); + const [allowFhirApi, setAllowFhirApi] = useState(false); + const [resourceTypes, setResourceTypes] = useState([ + "Patient", + "Observation", + ]); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + const [formError, setFormError] = useState(null); + const [showRevoked, setShowRevoked] = useState(false); + + const { data: links } = useQuery({ + queryKey: queryKeys.shareLinks(), + queryFn: async () => { + const res = await fetch("/api/share-links"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data.shareLinks as ShareLinkSummary[]; + }, + enabled: isAuthenticated, + }); + + const createMutation = useMutation({ + mutationFn: async () => { + const trimmed = label.trim(); + // Surface the same expiry-bound the server enforces before the round + // trip, so the validation feedback is immediate. + if (expiryDays < 1 || expiryDays > MAX_DAYS) { + throw new Error("EXPIRY_RANGE"); + } + const res = await fetch("/api/share-links", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + label: trimmed, + rangeStart: isoDaysFromNow(-rangeDays), + rangeEnd: null, + resourceTypes, + allowFhirApi, + expiresAt: isoDaysFromNow(expiryDays), + }), + }); + const json = await res.json(); + if (!res.ok) { + throw new Error(json.error || "FAILED"); + } + return json.data as ShareLinkSummary & { token: string }; + }, + onSuccess: (created) => { + setNewToken(created.token); + setCopied(false); + setLabel(""); + setFormError(null); + queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); + }, + onError: (err: Error) => { + setNewToken(null); + setFormError( + err.message === "EXPIRY_RANGE" + ? t("settings.sharing.expiryInvalid", { max: MAX_DAYS }) + : err.message === "FAILED" || err.message === "" + ? t("common.error") + : err.message, + ); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/share-links/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); + }, + }); + + function toggleResourceType(type: ResourceType) { + setResourceTypes((prev) => + prev.includes(type) + ? prev.filter((r) => r !== type) + : [...prev, type], + ); + } + + async function copyToken() { + if (!newToken) return; + try { + const origin = window.location.origin; + await navigator.clipboard.writeText(`${origin}/c/${newToken}`); + setCopied(true); + } catch { + // Clipboard can be unavailable (insecure context); the token stays + // visible in the card so the owner can copy it by hand. + } + } + + const activeLinks = useMemo( + () => (links ?? []).filter((l) => l.active), + [links], + ); + const inactiveLinks = useMemo( + () => (links ?? []).filter((l) => !l.active), + [links], + ); + + return ( +
+
+ +

+ {t("settings.sharing.createTitle")} +

+
+

+ {t("settings.sharing.createDescription")} +

+ +
{ + e.preventDefault(); + createMutation.mutate(); + }} + > +
+ + setLabel(e.target.value)} + placeholder={t("settings.sharing.labelPlaceholder")} + maxLength={120} + /> +
+ +
+
+ + + setRangeDays(Math.max(1, Number(e.target.value) || 1)) + } + /> +

+ {t("settings.sharing.rangeHint")} +

+
+
+ + + setExpiryDays(Math.max(1, Number(e.target.value) || 1)) + } + aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS} + /> +

+ {t("settings.sharing.expiryHint", { max: MAX_DAYS })} +

+
+
+ +
+
+ +

+ {t("settings.sharing.fhirApiHint")} +

+
+ +
+ + {allowFhirApi && ( +
+ + {t("settings.sharing.resourceTypes")} + +
+ {RESOURCE_TYPES.map((type) => ( + + ))} +
+
+ )} + + {formError && ( +

+ {formError} +

+ )} + + +
+ + {newToken && ( +
+

+ {t("settings.sharing.tokenCreated")} +

+

+ {t("settings.sharing.tokenOnce")} +

+
+ + {`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`} + + +
+ {copied && ( +

+ {t("settings.sharing.copied")} +

+ )} +
+ )} + +
+

+ {t("settings.sharing.activeTitle")} +

+ {activeLinks.length === 0 ? ( +

+ {t("settings.sharing.noActive")} +

+ ) : ( +
    + {activeLinks.map((link) => ( +
  • +
    +

    + {link.label} +

    +
    + + {t("settings.sharing.statusActive")} + + {link.allowFhirApi && ( + + FHIR + + )} +
    +
    +

    + + {t("settings.sharing.created")}: + {" "} + {formatDate(link.createdAt)} +

    +

    + + {t("settings.sharing.expires")}: + {" "} + {formatDateTime(link.expiresAt)} +

    +

    + + {t("settings.sharing.accessCount")}: + {" "} + {link.accessCount} + {link.lastAccessAt + ? ` · ${formatDateTime(link.lastAccessAt)}` + : ""} +

    + + + + + + + + {t("settings.sharing.revoke")} + + + {t("settings.sharing.revokeDescription")} + + + + + {t("common.cancel")} + + revokeMutation.mutate(link.id)} + > + {t("settings.sharing.revoke")} + + + + +
  • + ))} +
+ )} +
+ + {inactiveLinks.length > 0 && ( +
+ + {showRevoked && ( +
    + {inactiveLinks.map((link) => ( +
  • +
    +

    + {link.label} +

    + + {link.revokedAt + ? t("settings.sharing.statusRevoked") + : t("settings.sharing.statusExpired")} + +
    +

    + + {t("settings.sharing.accessCount")}: + {" "} + {link.accessCount} +

    +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 7d2347dad..8136de5c5 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -293,6 +293,10 @@ export const queryKeys = { researchMode: () => ["research-mode"] as const, moodlogStatus: () => ["moodlog-status"] as const, integrationsStatus: () => ["integrations", "status"] as const, + + /** v1.11.0 — owner's clinician share links (Settings → Sharing). */ + shareLinks: () => ["share-links"] as const, + featureFlags: () => ["feature-flags"] as const, coachPrefs: () => ["coach-prefs"] as const,