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>
);
}