feat: read-only FHIR R4 server (share records over /fhir)

Expose temetro's own records as a read-only FHIR R4 server at /fhir,
authenticated with per-clinic API keys (tmf_… bearer tokens, SHA-256
hashed, shown once). Serves Patient, Observation (labs + vitals),
AllergyIntolerance, Condition, MedicationRequest, Encounter and
Appointment as text-only CodeableConcepts (temetro stores free-text
clinical values); CapabilityStatement at /fhir/metadata (unauth).
Searchset Bundles with _count/_offset pagination and self/next/prev
links; every request is org-scoped and written to the activity log.
Keys are created/revoked under Settings → Integrations (owner/admin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-04 01:27:32 +03:00
parent bb536ba6da
commit 0d2494d67a
23 changed files with 6230 additions and 1 deletions
@@ -1,6 +1,6 @@
"use client";
import { CheckCircle2, CircleDashed, XCircle } from "lucide-react";
import { CheckCircle2, CircleDashed, Copy, KeyRound, Trash2, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -13,10 +13,15 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { API_BASE_URL } from "@/lib/api-client";
import {
createFhirKey,
type FhirApiKey,
type IntegrationConfig,
type IntegrationType,
listFhirKeys,
listIntegrations,
revokeFhirKey,
saveIntegration,
testIntegration,
} from "@/lib/integrations";
@@ -191,6 +196,216 @@ function IntegrationCard({
);
}
// The read-only FHIR R4 server. Unlike the integration cards above (which make
// temetro a FHIR *client*), this exposes temetro's own records over `/fhir` to
// external systems, authenticated with per-clinic API keys. Owner/admin only:
// the component self-gates by hiding when the keys fetch is forbidden.
function FhirServerCard() {
const { t } = useTranslation();
const [keys, setKeys] = useState<FhirApiKey[] | null>(null);
const [allowed, setAllowed] = useState(true);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [freshSecret, setFreshSecret] = useState<string | null>(null);
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
const baseUrl = `${API_BASE_URL}/fhir`;
useEffect(() => {
let active = true;
listFhirKeys()
.then((rows) => active && setKeys(rows))
.catch(() => {
if (active) {
setAllowed(false);
setKeys([]);
}
});
return () => {
active = false;
};
}, []);
const create = async () => {
if (!name.trim() || creating) return;
setCreating(true);
try {
const created = await createFhirKey(name.trim());
setFreshSecret(created.secret);
setKeys((prev) => [created, ...(prev ?? [])]);
setName("");
} catch {
notify.error(
t("settings.integrations.fhirServer.createFailed"),
t("settings.integrations.fhirServer.createFailedBody"),
);
} finally {
setCreating(false);
}
};
const revoke = async (id: string) => {
try {
await revokeFhirKey(id);
setKeys((prev) =>
(prev ?? []).map((k) => (k.id === id ? { ...k, revoked: true } : k)),
);
} catch {
notify.error(
t("settings.integrations.fhirServer.revokeFailed"),
t("settings.integrations.fhirServer.revokeFailedBody"),
);
} finally {
setConfirmRevoke(null);
}
};
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
notify.success(label, "");
} catch {
// Clipboard blocked — no-op; the value is visible for manual copy.
}
};
if (!allowed) return null;
return (
<SettingsSection
description={t("settings.integrations.fhirServer.description")}
title={t("settings.integrations.fhirServer.title")}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.fhirServer.baseUrl")}</FieldLabel>
<div className="flex items-center gap-2">
<Input readOnly value={baseUrl} />
<Button
onClick={() =>
copy(baseUrl, t("settings.integrations.fhirServer.copiedUrl"))
}
size="icon"
variant="outline"
>
<Copy className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.baseUrlHint")}
</p>
</div>
{freshSecret ? (
<div className="space-y-2 rounded-2xl border border-primary/40 bg-primary/5 p-4">
<p className="text-sm font-medium">
{t("settings.integrations.fhirServer.secretTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.secretHint")}
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg bg-muted px-3 py-2 font-mono text-xs">
{freshSecret}
</code>
<Button
onClick={() =>
copy(
freshSecret,
t("settings.integrations.fhirServer.copiedSecret"),
)
}
size="icon"
variant="outline"
>
<Copy className="size-4" />
</Button>
</div>
<Button onClick={() => setFreshSecret(null)} size="sm" variant="ghost">
{t("settings.integrations.fhirServer.dismissSecret")}
</Button>
</div>
) : null}
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.fhirServer.newKey")}</FieldLabel>
<div className="flex items-center gap-2">
<Input
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
placeholder={t("settings.integrations.fhirServer.newKeyPlaceholder")}
value={name}
/>
<Button disabled={creating || !name.trim()} onClick={create} size="sm">
<KeyRound className="size-4" />
{creating
? t("settings.integrations.fhirServer.creating")
: t("settings.integrations.fhirServer.create")}
</Button>
</div>
</div>
{keys && keys.length > 0 ? (
<ul className="divide-y rounded-2xl border">
{keys.map((k) => (
<li
key={k.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{k.name}</p>
<p className="text-xs text-muted-foreground">
{k.lastUsedAt
? t("settings.integrations.fhirServer.lastUsed", {
when: new Date(k.lastUsedAt).toLocaleString(),
})
: t("settings.integrations.fhirServer.neverUsed")}
</p>
</div>
{k.revoked ? (
<Badge variant="outline">
{t("settings.integrations.fhirServer.revoked")}
</Badge>
) : confirmRevoke === k.id ? (
<div className="flex items-center gap-2">
<Button
onClick={() => revoke(k.id)}
size="sm"
variant="destructive"
>
{t("settings.integrations.fhirServer.confirmRevoke")}
</Button>
<Button
onClick={() => setConfirmRevoke(null)}
size="sm"
variant="ghost"
>
{t("settings.integrations.fhirServer.cancel")}
</Button>
</div>
) : (
<Button
onClick={() => setConfirmRevoke(k.id)}
size="sm"
variant="outline"
>
<Trash2 className="size-4" />
{t("settings.integrations.fhirServer.revoke")}
</Button>
)}
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">
{t("settings.integrations.fhirServer.noKeys")}
</p>
)}
</SettingsCard>
</SettingsSection>
);
}
export function IntegrationsPanel() {
const { t } = useTranslation();
const [configs, setConfigs] = useState<IntegrationConfig[] | null>(null);
@@ -231,6 +446,7 @@ export function IntegrationsPanel() {
} satisfies IntegrationConfig);
return <IntegrationCard initial={initial} key={type} type={type} />;
})}
<FhirServerCard />
</div>
);
}
@@ -1692,6 +1692,32 @@
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON مع رمز مركز المقاصة ومعرّفات المرسل/المستقبل."
},
"fhirServer": {
"title": "خادم FHIR (مشاركة سجلاتك)",
"description": "اعرض سجلات هذه العيادة للأنظمة الخارجية كخادم FHIR R4 للقراءة فقط، مع المصادقة عبر مفاتيح API خاصة بكل عيادة.",
"baseUrl": "عنوان URL الأساسي",
"baseUrlHint": "وجّه عميل FHIR إلى هذا العنوان. يقدّم Patient وObservation وCondition وAllergyIntolerance وMedicationRequest وEncounter وAppointment.",
"copiedUrl": "تم نسخ العنوان الأساسي",
"newKey": "إنشاء مفتاح API",
"newKeyPlaceholder": "اسم المفتاح (مثل مستودع الأبحاث)",
"create": "إنشاء",
"creating": "جارٍ الإنشاء…",
"createFailed": "تعذّر إنشاء المفتاح",
"createFailedBody": "يرجى المحاولة مرة أخرى.",
"secretTitle": "انسخ مفتاح API الآن",
"secretHint": "هذه هي المرة الوحيدة التي يظهر فيها السر. احفظه في مكان آمن — لا يمكن استرجاعه لاحقًا.",
"copiedSecret": "تم نسخ مفتاح API",
"dismissSecret": "تم",
"noKeys": "لا توجد مفاتيح API بعد. أنشئ واحدًا للسماح لعميل FHIR بالاتصال.",
"lastUsed": "آخر استخدام {{when}}",
"neverUsed": "لم يُستخدم قط",
"revoke": "إبطال",
"confirmRevoke": "إبطال المفتاح",
"revoked": "تم الإبطال",
"revokeFailed": "تعذّر إبطال المفتاح",
"revokeFailedBody": "يرجى المحاولة مرة أخرى.",
"cancel": "إلغاء"
}
},
"empty": "لا شيء هنا بعد.",
@@ -1672,6 +1672,32 @@
"endpointPlaceholder": "https://ihre-verrechnungsstelle.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON mit Ihrem Token und Absender-/Empfänger-IDs der Verrechnungsstelle."
},
"fhirServer": {
"title": "FHIR-Server (Datensätze teilen)",
"description": "Stellen Sie die Datensätze dieser Praxis externen Systemen als schreibgeschützten FHIR-R4-Server bereit, authentifiziert über praxiseigene API-Schlüssel.",
"baseUrl": "Basis-URL",
"baseUrlHint": "Richten Sie einen FHIR-Client auf diese URL. Sie liefert Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter und Appointment.",
"copiedUrl": "Basis-URL kopiert",
"newKey": "API-Schlüssel erstellen",
"newKeyPlaceholder": "Schlüsselname (z. B. Forschungslager)",
"create": "Erstellen",
"creating": "Wird erstellt…",
"createFailed": "Schlüssel konnte nicht erstellt werden",
"createFailedBody": "Bitte erneut versuchen.",
"secretTitle": "Kopieren Sie Ihren API-Schlüssel jetzt",
"secretHint": "Das Geheimnis wird nur dieses eine Mal angezeigt. Bewahren Sie es sicher auf — es kann später nicht abgerufen werden.",
"copiedSecret": "API-Schlüssel kopiert",
"dismissSecret": "Fertig",
"noKeys": "Noch keine API-Schlüssel. Erstellen Sie einen, damit sich ein FHIR-Client verbinden kann.",
"lastUsed": "Zuletzt verwendet {{when}}",
"neverUsed": "Nie verwendet",
"revoke": "Widerrufen",
"confirmRevoke": "Schlüssel widerrufen",
"revoked": "Widerrufen",
"revokeFailed": "Schlüssel konnte nicht widerrufen werden",
"revokeFailedBody": "Bitte erneut versuchen.",
"cancel": "Abbrechen"
}
},
"empty": "Hier gibt es noch nichts.",
@@ -1672,6 +1672,32 @@
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids."
},
"fhirServer": {
"title": "FHIR server (share your records)",
"description": "Expose this clinic's records to external systems as a read-only FHIR R4 server, authenticated with per-clinic API keys.",
"baseUrl": "Base URL",
"baseUrlHint": "Point a FHIR client at this URL. It serves Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter and Appointment.",
"copiedUrl": "Base URL copied",
"newKey": "Create an API key",
"newKeyPlaceholder": "Key name (e.g. Research warehouse)",
"create": "Create",
"creating": "Creating…",
"createFailed": "Couldn't create the key",
"createFailedBody": "Please try again.",
"secretTitle": "Copy your API key now",
"secretHint": "This is the only time the secret is shown. Store it somewhere safe — it can't be retrieved later.",
"copiedSecret": "API key copied",
"dismissSecret": "Done",
"noKeys": "No API keys yet. Create one to let a FHIR client connect.",
"lastUsed": "Last used {{when}}",
"neverUsed": "Never used",
"revoke": "Revoke",
"confirmRevoke": "Revoke key",
"revoked": "Revoked",
"revokeFailed": "Couldn't revoke the key",
"revokeFailedBody": "Please try again.",
"cancel": "Cancel"
}
},
"empty": "Nothing here yet.",
@@ -1672,6 +1672,32 @@
"endpointPlaceholder": "https://votre-chambre-compensation.example/claims",
"credentialsPlaceholder": "JSON : {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON avec le jeton de votre chambre de compensation et les identifiants d'expéditeur/destinataire."
},
"fhirServer": {
"title": "Serveur FHIR (partager vos dossiers)",
"description": "Exposez les dossiers de cette clinique à des systèmes externes via un serveur FHIR R4 en lecture seule, authentifié par des clés d'API propres à la clinique.",
"baseUrl": "URL de base",
"baseUrlHint": "Pointez un client FHIR vers cette URL. Elle expose Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter et Appointment.",
"copiedUrl": "URL de base copiée",
"newKey": "Créer une clé d'API",
"newKeyPlaceholder": "Nom de la clé (ex. Entrepôt de recherche)",
"create": "Créer",
"creating": "Création…",
"createFailed": "Impossible de créer la clé",
"createFailedBody": "Veuillez réessayer.",
"secretTitle": "Copiez votre clé d'API maintenant",
"secretHint": "Le secret n'est affiché qu'une seule fois. Conservez-le en lieu sûr — il ne pourra pas être récupéré ensuite.",
"copiedSecret": "Clé d'API copiée",
"dismissSecret": "Terminé",
"noKeys": "Aucune clé d'API pour l'instant. Créez-en une pour qu'un client FHIR puisse se connecter.",
"lastUsed": "Dernière utilisation {{when}}",
"neverUsed": "Jamais utilisée",
"revoke": "Révoquer",
"confirmRevoke": "Révoquer la clé",
"revoked": "Révoquée",
"revokeFailed": "Impossible de révoquer la clé",
"revokeFailedBody": "Veuillez réessayer.",
"cancel": "Annuler"
}
},
"empty": "Rien ici pour le moment.",
@@ -1672,6 +1672,32 @@
"endpointPlaceholder": "https://xarunta-xisaabintaada.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON leh token-ka xarunta xisaabinta iyo aqoonsiyada diraha/qaataha."
},
"fhirServer": {
"title": "Serfarka FHIR (la wadaag diiwaannadaada)",
"description": "U soo bandhig diiwaannada rugtan nidaamyada dibadda ah sida serfar FHIR R4 akhris-oo-keliya, oo lagu ansixiyo furayaal API oo rug walba gaar u ah.",
"baseUrl": "URL-ka aasaasiga ah",
"baseUrlHint": "U tilmaam macmiil FHIR URL-kan. Wuxuu adeegaa Patient, Observation, Condition, AllergyIntolerance, MedicationRequest, Encounter iyo Appointment.",
"copiedUrl": "URL-ka aasaasiga waa la koobiyeeyay",
"newKey": "Samee fure API",
"newKeyPlaceholder": "Magaca furaha (tusaale, Bakhaarka cilmi-baarista)",
"create": "Samee",
"creating": "Waa la samaynayaa…",
"createFailed": "Furaha lama abuuri karin",
"createFailedBody": "Fadlan mar kale isku day.",
"secretTitle": "Hadda koobiyee furahaaga API",
"secretHint": "Tanu waa markii kaliya ee sirta la muujiyo. Meel ammaan ah ku kaydi — lama soo ceshan karo dabadeed.",
"copiedSecret": "Furaha API waa la koobiyeeyay",
"dismissSecret": "Diyaar",
"noKeys": "Weli ma jiraan furayaal API ah. Mid samee si macmiil FHIR u xidho.",
"lastUsed": "Markii ugu dambeysay la isticmaalay {{when}}",
"neverUsed": "Weligeed lama isticmaalin",
"revoke": "Baabbi'i",
"confirmRevoke": "Baabbi'i furaha",
"revoked": "La baabbi'iyay",
"revokeFailed": "Furaha lama baabbi'in karin",
"revokeFailedBody": "Fadlan mar kale isku day.",
"cancel": "Jooji"
}
},
"empty": "Weli halkan waxba ma jiraan.",
+31
View File
@@ -64,6 +64,37 @@ export function submitInsuranceClaim(
});
}
// --- FHIR server API keys (owner/admin only) --------------------------------
export type FhirApiKey = {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
revoked: boolean;
};
// A freshly created key includes the one-time plaintext secret; it is never
// returned again.
export type CreatedFhirApiKey = FhirApiKey & { secret: string };
export function listFhirKeys(): Promise<FhirApiKey[]> {
return apiFetch<FhirApiKey[]>("/api/integrations/fhir-server/keys");
}
export function createFhirKey(name: string): Promise<CreatedFhirApiKey> {
return apiFetch<CreatedFhirApiKey>("/api/integrations/fhir-server/keys", {
method: "POST",
body: JSON.stringify({ name }),
});
}
export function revokeFhirKey(id: string): Promise<{ revoked: boolean }> {
return apiFetch(`/api/integrations/fhir-server/keys/${id}`, {
method: "DELETE",
});
}
// Convenience hook-style fetch reused by the on-page sections: returns the
// config for one type (or null while loading/absent).
export async function getIntegration(