mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 01:47:01 +00:00
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:
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user