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 (
+
+
+