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