feat: patient wallet — real Signing, encrypted share relay, import-from-app

Backend
- clinic Ed25519 signing key (services/signing.ts, routes/signing.ts,
  clinic_signing_keys table) — Settings → Signing is now real
- @noble wallet-crypto (lib/wallet-crypto.ts): ed25519 identity, base58check
  wallet numbers, sealed-box (x25519 + xchacha20poly1305)
- /wallet Socket.io relay namespace (challenge-signed device auth) forwarding
  only ciphertext; emitToWallet helper
- import-from-app flow (routes/patients-wallet.ts, services/wallet-share.ts):
  request-share → patient approval → decrypt + verify → review draft → commit
- temporary shares: patients.share_expires_at + 5-min auto-delete sweep; revoke

Frontend
- SigningPanel wired to live key/fingerprint/rotate + shared-records list
- "Import from a patient app" dialog (lib/signing.ts, import-from-wallet-dialog)
  reusing the draft-review path; temporary badge on the patient list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-21 18:19:57 +03:00
parent b74d5d2d05
commit 2d47abcc42
25 changed files with 5951 additions and 16 deletions
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
@@ -11,9 +12,77 @@ import {
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import {
getSigningKey,
listSignedRecords,
rotateSigningKey,
type SharedRecord,
type SigningKey,
} from "@/lib/signing";
import { notify } from "@/lib/toast";
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function SigningPanel() {
const { t } = useTranslation();
const [key, setKey] = useState<SigningKey | null>(null);
const [records, setRecords] = useState<SharedRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rotating, setRotating] = useState(false);
useEffect(() => {
let active = true;
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
.then(([k, r]) => {
if (!active) return;
setKey(k);
setRecords(r);
setError(null);
})
.catch(() => {
if (active) setError(t("settings.signing.loadError"));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [t]);
const rotate = async () => {
setRotating(true);
try {
const next = await rotateSigningKey();
setKey(next);
notify.success(
t("settings.signing.rotateSuccessTitle"),
t("settings.signing.rotateSuccessBody"),
);
} catch {
notify.error(
t("settings.signing.rotateErrorTitle"),
t("settings.signing.rotateError"),
);
} finally {
setRotating(false);
}
};
const recordStatusLabel = (status: SharedRecord["status"]): string =>
t(
`settings.signing.records.status${
status.charAt(0).toUpperCase() + status.slice(1)
}`,
);
return (
<>
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
@@ -29,15 +98,35 @@ export function SigningPanel() {
<p className="text-sm text-muted-foreground">
{t("settings.signing.keyDescription")}
</p>
<Button className={cn("rounded-lg", whiteButton)}>
{t("settings.signing.rotateKey")}
<Button
className={cn("rounded-lg", whiteButton)}
disabled={rotating || loading}
onClick={rotate}
type="button"
>
{rotating
? t("settings.signing.rotating")
: t("settings.signing.rotateKey")}
</Button>
</div>
<div className="sm:text-right">
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
<p className="text-sm text-muted-foreground">
{t("settings.signing.createdAt")}
{key
? t("settings.signing.createdAtLabel", {
date: formatDate(key.createdAt),
})
: loading
? t("settings.signing.loading")
: error ?? ""}
</p>
{key?.rotatedAt ? (
<p className="text-xs text-muted-foreground">
{t("settings.signing.rotatedAtLabel", {
date: formatDate(key.rotatedAt),
})}
</p>
) : null}
</div>
</SettingsCard>
@@ -49,7 +138,7 @@ export function SigningPanel() {
<CopyField
description={t("settings.signing.fingerprintDescription")}
label={t("settings.signing.fingerprintLabel")}
value="ed25519:9f86 d081 884c 7d65 9a2f eaa0 c55a d015"
value={key?.fingerprint ?? "—"}
/>
</SettingsCard>
</SettingsSection>
@@ -98,11 +187,41 @@ export function SigningPanel() {
description={t("settings.signing.signedRecordsDescription")}
title={t("settings.signing.signedRecordsTitle")}
>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("settings.signing.noPending")}
</p>
</SettingsCard>
{records.length === 0 ? (
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("settings.signing.noPending")}
</p>
</SettingsCard>
) : (
<SettingsCard className="divide-y divide-border">
{records.map((record) => (
<div
className="flex items-center justify-between gap-3 px-4 py-3.5"
key={record.id}
>
<div className="min-w-0 space-y-0.5">
<p className="truncate font-mono text-sm">
{record.walletNumber}
</p>
<p className="text-xs text-muted-foreground">
{record.shareMode === "temporary"
? t("settings.signing.records.temporary")
: t("settings.signing.records.permanent")}
{record.shareExpiresAt
? ` · ${t("settings.signing.records.expires", {
date: formatDate(record.shareExpiresAt),
})}`
: ""}
</p>
</div>
<Badge variant="secondary">
{recordStatusLabel(record.status)}
</Badge>
</div>
))}
</SettingsCard>
)}
</SettingsSection>
</>
);