diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 4b5c1a3f0..8e7d77599 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -24,6 +24,7 @@ import { VorsorgeSection } from "@/components/settings/vorsorge-section"; // v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries // again (split out of the Integrations sub-tabs). import { ChannelsSection } from "@/components/settings/channels-section"; +import { SharingSection } from "@/components/settings/sharing-section"; import { SourcesSection } from "@/components/settings/sources-section"; import { ThresholdsSection } from "@/components/settings/thresholds-section"; import { @@ -73,6 +74,7 @@ const SECTION_COMPONENTS: Record< thresholds: ThresholdsSection, api: ApiSection, gesundheitsakte: GesundheitsakteSection, + 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 19b600232..da80f4bed 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -94,6 +94,9 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.18.7 — `labs`, `illness`, `vorsorge` move into the shell as // first-class sections, right after `mood` (the three were standalone // `ModuleSettingsFrame` pages). + // v1.18.7 — `sharing` (clinician share links) sits directly after + // `gesundheitsakte`, before `export`: minting a read-only link to the + // health record belongs next to the health-record export. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "modules", @@ -114,6 +117,7 @@ describe("SETTINGS_SECTION_SLUGS", () => { "coach", "api", "gesundheitsakte", + "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-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 3a56bdbb4..577f0c2dc 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -27,6 +27,7 @@ import { Pill, Radio, Settings2, + Share2, SlidersHorizontal, Smile, Sparkles, @@ -60,6 +61,7 @@ const SLUG_ICON: Record = { coach: Bot, api: KeyRound, gesundheitsakte: FileHeart, + sharing: Share2, export: Download, advanced: Settings2, about: Info, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 80b5d3d68..48419670d 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -74,6 +74,13 @@ export const SETTINGS_SECTION_SLUGS = [ "coach", "api", "gesundheitsakte", + // v1.18.7 — `sharing` (clinician share links) sits directly after + // `gesundheitsakte`, before `export`: minting a time-boxed read-only link + // to the health record belongs next to the health-record export. Always + // available (no module gate), like account / export. The backing model, + // the `/api/share-links` routes, and the public `/c/[token]` view are + // unchanged — this restores only the owner Settings surface. + "sharing", "export", "advanced", "about", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 0f709910a..7fff82751 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -33,6 +33,7 @@ import { Pill, Radio, Settings2, + Share2, SlidersHorizontal, Smile, Sparkles, @@ -224,6 +225,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.gesundheitsakte.title", icon: FileHeart, }, + // v1.18.7 — clinician share links sit next to the health record: minting a + // time-boxed read-only link is a sharing face of the same data. Not + // module-gated; the link surface is always available (the public + // `/c/[token]` view and the `/api/share-links` lifecycle are unchanged). + { + 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..65f86d9cf --- /dev/null +++ b/src/components/settings/sharing-section.tsx @@ -0,0 +1,485 @@ +"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. + * + * v1.18.7 — restored as a first-class Settings section. The visible heading + + * subtitle now come from the shared shell chrome in the route; this body is + * the share-links card. Cards paint through `` so the surface + * matches every sibling section 1:1. + */ +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 { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useAuth } from "@/hooks/use-auth"; +import { formatDate, formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; + +/** 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() { + // v1.18.7 — the visible heading + subtitle come from the shared settings + // shell chrome in the route; this body is the share-links card only. + return ( +
+ +
+ ); +} + +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: () => + apiGet<{ shareLinks: ShareLinkSummary[] }>("/api/share-links").then( + (data) => data.shareLinks, + ), + enabled: isAuthenticated, + }); + + const createMutation = useMutation({ + mutationFn: () => { + 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) { + return Promise.reject(new Error("EXPIRY_RANGE")); + } + return apiPost( + "/api/share-links", + { + label: trimmed, + rangeStart: isoDaysFromNow(-rangeDays), + rangeEnd: null, + resourceTypes, + allowFhirApi, + expiresAt: isoDaysFromNow(expiryDays), + }, + ); + }, + 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 }) + : t("common.error"), + ); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`), + 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 ( + + + +
{ + 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} +

    +
  • + ))} +
+ )} +
+ )} +
+ ); +}