mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 18:07:44 +00:00
feat: multi-clinic Temetro Network with per-clinic identity (v0.8.0)
The relay is now multi-clinic. Each clinic authenticates to the /hub namespace by signing a challenge with its own Ed25519 clinic signing key (a per-clinic identity, not a shared RELAY_TOKEN), and the relay routes every device response back to only the clinic that originated the request (keyed by requestId) — so clinics never see each other's traffic. Backend: - clinic_signing_keys.network_enabled + GET/PUT /api/signing/network (owner/admin) to join/leave the network. - relay-client keeps one authenticated hub connection per network-enabled org (connectOrg/disconnectOrg, hubs map keyed by orgId); emitToWallet/ sendToWallet take orgId; offline flush is org-scoped. - Wallet import/push return 409 until a clinic joins. - RELAY_TOKEN is now optional/legacy (open relay needs no shared secret). Frontend: - "Join Temetro Network" toggle in Settings → Signing, localized in all five languages (en, fr, de, so, ar). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
CopyField,
|
||||
SettingsCard,
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import {
|
||||
getNetworkEnabled,
|
||||
getSigningKey,
|
||||
listSignedRecords,
|
||||
rotateSigningKey,
|
||||
setNetworkEnabled,
|
||||
type SharedRecord,
|
||||
type SigningKey,
|
||||
} from "@/lib/signing";
|
||||
@@ -38,6 +41,8 @@ export function SigningPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [networkOn, setNetworkOn] = useState(false);
|
||||
const [networkSaving, setNetworkSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -45,12 +50,14 @@ export function SigningPanel() {
|
||||
getSigningKey(),
|
||||
listSignedRecords().catch(() => []),
|
||||
listWalletUpdates().catch(() => []),
|
||||
getNetworkEnabled().catch(() => false),
|
||||
])
|
||||
.then(([k, r, u]) => {
|
||||
.then(([k, r, u, n]) => {
|
||||
if (!active) return;
|
||||
setKey(k);
|
||||
setRecords(r);
|
||||
setUpdates(u);
|
||||
setNetworkOn(n);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -83,6 +90,32 @@ export function SigningPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNetwork = async (next: boolean) => {
|
||||
setNetworkSaving(true);
|
||||
// Optimistic — revert on failure.
|
||||
setNetworkOn(next);
|
||||
try {
|
||||
const saved = await setNetworkEnabled(next);
|
||||
setNetworkOn(saved);
|
||||
notify.success(
|
||||
next
|
||||
? t("settings.network.joinedTitle")
|
||||
: t("settings.network.leftTitle"),
|
||||
next
|
||||
? t("settings.network.joinedBody")
|
||||
: t("settings.network.leftBody"),
|
||||
);
|
||||
} catch {
|
||||
setNetworkOn(!next);
|
||||
notify.error(
|
||||
t("settings.network.errorTitle"),
|
||||
t("settings.network.error"),
|
||||
);
|
||||
} finally {
|
||||
setNetworkSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recordStatusLabel = (status: SharedRecord["status"]): string =>
|
||||
t(
|
||||
`settings.signing.records.status${
|
||||
@@ -137,6 +170,41 @@ export function SigningPanel() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.network.description")}
|
||||
title={t("settings.network.title")}
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.network.toggleLabel")}
|
||||
</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
networkOn
|
||||
? "bg-emerald-500/15 text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{networkOn
|
||||
? t("settings.network.statusConnected")
|
||||
: t("settings.network.statusOff")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.network.toggleDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={t("settings.network.toggleLabel")}
|
||||
checked={networkOn}
|
||||
disabled={loading || networkSaving}
|
||||
onCheckedChange={toggleNetwork}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.signing.identityDescription")}
|
||||
title={t("settings.signing.identityTitle")}
|
||||
|
||||
@@ -1996,6 +1996,20 @@
|
||||
"errorTitle": "تعذّر إضافة العضو"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "شبكة Temetro",
|
||||
"description": "يتيح مُرحّل شبكة Temetro لتطبيقات محفظة المرضى الاتصال بعيادتك لمشاركة السجلات والموافقة عليها — مشفّرة من طرف إلى طرف، مع تعريف عيادتك بمفتاح التوقيع الخاص بها.",
|
||||
"toggleLabel": "الانضمام إلى شبكة Temetro",
|
||||
"toggleDesc": "عند التفعيل، تتصل هذه العيادة بالمُرحّل لاستيراد السجلات من تطبيقات المرضى وإرسال التحديثات إلى محافظهم.",
|
||||
"statusConnected": "متصل",
|
||||
"statusOff": "معطّل",
|
||||
"joinedTitle": "تم الانضمام إلى شبكة Temetro",
|
||||
"joinedBody": "يمكن الآن لتطبيقات محفظة المرضى الاتصال بهذه العيادة.",
|
||||
"leftTitle": "تم مغادرة شبكة Temetro",
|
||||
"leftBody": "لم تعد هذه العيادة متصلة بالمُرحّل.",
|
||||
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
|
||||
"error": "يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "مفتاح التوقيع",
|
||||
"active": "نشط",
|
||||
|
||||
@@ -1976,6 +1976,20 @@
|
||||
"errorTitle": "Mitglied konnte nicht hinzugefügt werden"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "Temetro-Netzwerk",
|
||||
"description": "Das Temetro-Netzwerk-Relay ermöglicht es den Wallet-Apps der Patienten, sich mit Ihrer Praxis zu verbinden, um Akten zu teilen und zu genehmigen — Ende-zu-Ende-verschlüsselt, wobei Ihre Praxis über ihren Signierschlüssel identifiziert wird.",
|
||||
"toggleLabel": "Temetro-Netzwerk beitreten",
|
||||
"toggleDesc": "Wenn aktiviert, verbindet sich diese Praxis mit dem Relay, sodass Sie Akten aus Patienten-Apps importieren und Aktualisierungen an deren Wallets senden können.",
|
||||
"statusConnected": "Verbunden",
|
||||
"statusOff": "Aus",
|
||||
"joinedTitle": "Temetro-Netzwerk beigetreten",
|
||||
"joinedBody": "Wallet-Apps der Patienten können sich jetzt mit dieser Praxis verbinden.",
|
||||
"leftTitle": "Temetro-Netzwerk verlassen",
|
||||
"leftBody": "Diese Praxis ist nicht mehr mit dem Relay verbunden.",
|
||||
"errorTitle": "Netzwerkzugriff konnte nicht aktualisiert werden",
|
||||
"error": "Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Signierschlüssel",
|
||||
"active": "Aktiv",
|
||||
|
||||
@@ -1976,6 +1976,20 @@
|
||||
"errorTitle": "Could not add member"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "Temetro Network",
|
||||
"description": "The Temetro Network relay lets patients' wallet apps connect to your clinic to share and approve records — end-to-end encrypted, with your clinic identified by its signing key.",
|
||||
"toggleLabel": "Join Temetro Network",
|
||||
"toggleDesc": "When on, this clinic connects to the relay so you can import records from patient apps and push updates to their wallets.",
|
||||
"statusConnected": "Connected",
|
||||
"statusOff": "Off",
|
||||
"joinedTitle": "Joined the Temetro Network",
|
||||
"joinedBody": "Patient wallet apps can now connect to this clinic.",
|
||||
"leftTitle": "Left the Temetro Network",
|
||||
"leftBody": "This clinic is no longer connected to the relay.",
|
||||
"errorTitle": "Couldn't update network access",
|
||||
"error": "Please try again."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Signing key",
|
||||
"active": "Active",
|
||||
|
||||
@@ -1976,6 +1976,20 @@
|
||||
"errorTitle": "Impossible d'ajouter le membre"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "Réseau Temetro",
|
||||
"description": "Le relais Réseau Temetro permet aux applications portefeuille des patients de se connecter à votre clinique pour partager et approuver des dossiers — chiffrés de bout en bout, votre clinique étant identifiée par sa clé de signature.",
|
||||
"toggleLabel": "Rejoindre le Réseau Temetro",
|
||||
"toggleDesc": "Une fois activé, cette clinique se connecte au relais pour importer des dossiers depuis les applications des patients et envoyer des mises à jour vers leurs portefeuilles.",
|
||||
"statusConnected": "Connecté",
|
||||
"statusOff": "Désactivé",
|
||||
"joinedTitle": "Réseau Temetro rejoint",
|
||||
"joinedBody": "Les applications portefeuille des patients peuvent désormais se connecter à cette clinique.",
|
||||
"leftTitle": "Réseau Temetro quitté",
|
||||
"leftBody": "Cette clinique n'est plus connectée au relais.",
|
||||
"errorTitle": "Impossible de mettre à jour l'accès au réseau",
|
||||
"error": "Veuillez réessayer."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Clé de signature",
|
||||
"active": "Active",
|
||||
|
||||
@@ -1976,6 +1976,20 @@
|
||||
"errorTitle": "Xubinta lama ku dari karin"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "Shabakadda Temetro",
|
||||
"description": "Gudbiyaha Shabakadda Temetro wuxuu u oggolaanayaa abaabulka boorsada bukaannada inay ku xidhaan rugtaada si ay u wadaagaan oo u ansixiyaan diiwaannada — sir gaba-gabo ah, iyadoo rugtaada lagu aqoonsado furaheeda saxiixa.",
|
||||
"toggleLabel": "Ku biir Shabakadda Temetro",
|
||||
"toggleDesc": "Marka la shido, rugtan waxay ku xidhmaysaa gudbiyaha si aad u soo dejiso diiwaannada abaabulka bukaannada oo aad cusboonaysiin ugu dirto boorsadooda.",
|
||||
"statusConnected": "La xidhiidhay",
|
||||
"statusOff": "Daminaan",
|
||||
"joinedTitle": "Waxaad ku biirtay Shabakadda Temetro",
|
||||
"joinedBody": "Abaabulka boorsada bukaannada hadda way ku xidhmi karaan rugtan.",
|
||||
"leftTitle": "Waxaad ka baxday Shabakadda Temetro",
|
||||
"leftBody": "Rugtan hadda kuma xidhna gudbiyaha.",
|
||||
"errorTitle": "Lama cusboonaysiin karin gelitaanka shabakadda",
|
||||
"error": "Fadlan isku day mar kale."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "Furaha saxiixa",
|
||||
"active": "Firfircoon",
|
||||
|
||||
@@ -38,3 +38,21 @@ export async function rotateSigningKey(): Promise<SigningKey> {
|
||||
export async function listSignedRecords(): Promise<SharedRecord[]> {
|
||||
return apiFetch<SharedRecord[]>("/api/signing/records");
|
||||
}
|
||||
|
||||
// Whether this clinic has joined the Temetro Network relay (patient-wallet
|
||||
// sharing rides it). Readable by any clinician.
|
||||
export async function getNetworkEnabled(): Promise<boolean> {
|
||||
const { enabled } = await apiFetch<{ enabled: boolean }>(
|
||||
"/api/signing/network",
|
||||
);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// Join or leave the Temetro Network (owner/admin only). Returns the new state.
|
||||
export async function setNetworkEnabled(enabled: boolean): Promise<boolean> {
|
||||
const res = await apiFetch<{ enabled: boolean }>("/api/signing/network", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
return res.enabled;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user