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/<token>` 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 01:39:58 +02:00
1 parent f4629a17d6
commit be94e43480
13 files changed
+864 -8

No files matched your search

+35 -1
View File
@@ -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",
+35 -1
View File
@@ -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",
+35 -1
View File
@@ -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",
+35 -1
View File
@@ -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",
+35 -1
View File
@@ -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",
+35 -1
View File
@@ -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",
+2
View File
@@ -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: () => <SourcesSection />,
api: ApiSection,
sharing: SharingSection,
export: ExportSection,
advanced: AdvancedSection,
};
@@ -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",
@@ -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(
<I18nProvider initialLocale={locale}>
<SharingSection />
</I18nProvider>,
);
}
describe("<SharingSection> — 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");
});
});
+4
View File
@@ -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",
@@ -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",
+508
View File
@@ -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/<token>`, 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 (
<section
aria-labelledby="settings-section-sharing-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-sharing-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.sharing.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sharing.description")}
</p>
</header>
<ShareLinksCard />
</section>
);
}
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<ResourceType[]>([
"Patient",
"Observation",
]);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [formError, setFormError] = useState<string | null>(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 (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<div className="flex items-center gap-2">
<Share2 className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.sharing.createTitle")}
</h2>
</div>
<p className="text-muted-foreground -mt-4 text-xs">
{t("settings.sharing.createDescription")}
</p>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
<Input
id="share-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("settings.sharing.labelPlaceholder")}
maxLength={120}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
<Input
id="share-range"
type="number"
min={1}
max={3650}
value={rangeDays}
onChange={(e) =>
setRangeDays(Math.max(1, Number(e.target.value) || 1))
}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.rangeHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-expiry">
{t("settings.sharing.expiry")}
</Label>
<Input
id="share-expiry"
type="number"
min={1}
max={MAX_DAYS}
value={expiryDays}
onChange={(e) =>
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
}
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
</p>
</div>
</div>
<div className="border-border flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5 pr-3">
<Label htmlFor="share-fhir" className="text-sm font-medium">
{t("settings.sharing.fhirApi")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.fhirApiHint")}
</p>
</div>
<Switch
id="share-fhir"
checked={allowFhirApi}
onCheckedChange={setAllowFhirApi}
/>
</div>
{allowFhirApi && (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharing.resourceTypes")}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{RESOURCE_TYPES.map((type) => (
<label
key={type}
className="flex items-center gap-2 text-sm"
htmlFor={`share-rt-${type}`}
>
<Checkbox
id={`share-rt-${type}`}
checked={resourceTypes.includes(type)}
onCheckedChange={() => toggleResourceType(type)}
/>
<span className="font-mono text-xs">{type}</span>
</label>
))}
</div>
</fieldset>
)}
{formError && (
<p role="alert" className="text-destructive text-sm">
{formError}
</p>
)}
<Button
type="submit"
disabled={createMutation.isPending || !label.trim()}
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
</form>
{newToken && (
<div
className="bg-dracula-green/10 space-y-2 rounded-lg p-3 text-sm"
data-testid="share-token-reveal"
>
<p className="text-dracula-green font-medium">
{t("settings.sharing.tokenCreated")}
</p>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.tokenOnce")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={copyToken}
aria-label={t("settings.sharing.copy")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied && (
<p className="text-dracula-green text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
</div>
)}
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<p
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm"
data-testid="share-active-empty"
>
{t("settings.sharing.noActive")}
</p>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-dracula-green/15 text-dracula-green text-[10px]">
{t("settings.sharing.statusActive")}
</Badge>
{link.allowFhirApi && (
<Badge variant="outline" className="text-[10px]">
FHIR
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
</div>
{inactiveLinks.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<Badge variant="secondary" className="text-[10px]">
{link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
</p>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+4
View File
@@ -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,