mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(sharing): the shared-access panel and the record-activity view
Settings gains one section for one concept: who else can open this health record, whose this account may open, and when they were opened. Split across two pages it would be two half-answers. An invitation confers nothing until the other person accepts it, and the card says so above the field rather than after the submit — that is the part somebody needs to know before they type a name. Refusals are named from the stable errorCode, because "no account with that name" and "that person already has access" are different situations with different next steps. Ended grants stay in the list. The row is the consent record, and "who had access, from when to when, and who ended it" is the question the panel exists to answer; a list that dropped them could not. Revoking asks once and then does it: reducing access must never be harder than granting it was. `GET /api/account/activity` answers the owner's other question. One row per person per day with a count, not one per request — a hundred identical lines is a way of answering nothing. The window is published with the rows rather than assumed: audit history is purged on a schedule the operator sets, so the view prints the number the server resolved from the same setting the purge job reads. An instance running ninety days says ninety. Six locales ship with it.
This commit is contained in:
1 parent
dd3d5646de
commit
5199de78af
20 files changed
+1620
No files matched your search
@@ -498,6 +498,40 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// ── Account sharing ─────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/settings/access/grants-given-card.tsx",
|
||||
destroys:
|
||||
"another account's standing access to this health record, and every browser session of theirs that was inside it",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
undetected:
|
||||
"No detector sees it. The DELETE goes out from `useRevokeGrant` in `lib/queries/use-account-grants.ts`, and `lib/` is an excluded root; the card itself only calls `.mutate(grant.id)` with a bare string, which no payload pattern matches, and the route path lives in the hook rather than here. Registered by hand.",
|
||||
},
|
||||
{
|
||||
file: "components/settings/access/grants-received-card.tsx",
|
||||
destroys:
|
||||
"this account's own access to somebody else's record, handed back rather than withdrawn",
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
undetected:
|
||||
"Same shape as the card above, and one step further out of reach: renouncing is a POST, so not even a DELETE goes out. The path (`/api/account/grants/{id}/renounce`) names an act the D2 vocabulary does not carry, and it lives in the hook regardless. Registered by hand.",
|
||||
},
|
||||
{
|
||||
file: "components/settings/access/grant-invite-card.tsx",
|
||||
destroys:
|
||||
"nothing — it offers access, and the offer confers nothing until the other person accepts it",
|
||||
recovery: "permanent",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "the invitation form",
|
||||
reason:
|
||||
"D3 flags this file for `expiresAt: null` in the invite payload, reading it as a field being blanked. It is not: null means the grant runs until somebody ends it, which is the common household case and the absence of an expiry rather than the removal of one. The form creates a pending row and destroys nothing, and gating a creation behind a confirmation dialog would teach people to click through the dialogs that do guard a loss.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Admin ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/admin/danger-zone-section.tsx",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* `GET /api/account/activity` — who opened this account's record, and when.
|
||||
*
|
||||
* The owner's side of the audit actor. Every row filed under this account that
|
||||
* somebody ELSE performed, newest first: the day-coalesced read rows the
|
||||
* delegated resolver writes, and any delegated action that lands an audit row
|
||||
* of its own. Rows the owner performed themselves are absent by construction —
|
||||
* `actorUserId` is null for those, and this feed is not a second copy of the
|
||||
* security-activity feed.
|
||||
*
|
||||
* **The window is published, not implied.** `retentionDays` rides the response
|
||||
* because audit rows are purged on a schedule the operator configures
|
||||
* (`AUDIT_LOG_RETENTION_DAYS`, 365 by default) and a view that showed "the
|
||||
* activity" without saying how far back it goes would claim a completeness it
|
||||
* does not have. The number is resolved server-side from the same function the
|
||||
* purge job uses, so the sentence in the UI cannot drift from the job that
|
||||
* makes it true — an instance running a 90-day window says 90.
|
||||
*
|
||||
* Not delegable: bare `requireAuth()` refuses under a switch. A delegate must
|
||||
* not be able to read the owner's record of who has been in it — that list
|
||||
* names other delegates, and a grant is not an introduction to the household.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* How many rows the feed returns. One row per (delegate, day) for reads, so a
|
||||
* hundred covers months of a busy household — and a longer list answers a
|
||||
* question nobody asked while making the recent activity harder to see.
|
||||
*/
|
||||
const MAX_ROWS = 100;
|
||||
|
||||
export interface RecordActivityEntry {
|
||||
id: string;
|
||||
/**
|
||||
* Who did it. Null when that account has since been deleted: the id on the
|
||||
* row is deliberately not a foreign key, so history is not rewritten when a
|
||||
* person leaves. A row whose actor cannot be resolved says "a deleted
|
||||
* account", which is true, rather than silently reading as the owner's own.
|
||||
*/
|
||||
actor: { id: string; username: string; displayName: string | null } | null;
|
||||
action: string;
|
||||
/**
|
||||
* For a coalesced read row, how many times that delegate opened the record
|
||||
* that day. Null for anything else — a single act has no count, and a `1`
|
||||
* there would invite the reader to add up numbers that mean different things.
|
||||
*/
|
||||
accesses: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The `accesses` counter, when the row is a coalesced read. */
|
||||
function readAccessCount(
|
||||
action: string,
|
||||
details: string | null,
|
||||
): number | null {
|
||||
if (action !== DELEGATED_ACCESS_ACTION || details === null) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(details);
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const value = (parsed as { accesses?: unknown }).accesses;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
// A row whose details cannot be parsed still happened. Dropping the count
|
||||
// loses a number; dropping the row would lose the access.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
actorUserId: { not: null },
|
||||
// A switch into one's own record is not a delegation and has nothing to
|
||||
// report. `actingActorFor` already refuses to stamp one, so this is the
|
||||
// second end of that rule rather than its only enforcement.
|
||||
NOT: { actorUserId: user.id },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: MAX_ROWS,
|
||||
select: {
|
||||
id: true,
|
||||
actorUserId: true,
|
||||
action: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const actorIds = [
|
||||
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
|
||||
];
|
||||
const actors = await prisma.user.findMany({
|
||||
where: { id: { in: actorIds } },
|
||||
select: GRANT_PARTY_SELECT,
|
||||
});
|
||||
const byId = new Map(actors.map((a) => [a.id, a]));
|
||||
|
||||
const entries: RecordActivityEntry[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
actor: row.actorUserId ? (byId.get(row.actorUserId) ?? null) : null,
|
||||
action: row.action,
|
||||
accesses: readAccessCount(row.action, row.details),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "sharing.activity.list" },
|
||||
meta: { entry_count: entries.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
entries,
|
||||
// The bound the view states out loud. Resolved here so the UI never has to
|
||||
// hardcode a number the operator can change.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { AccessSection } from "@/components/settings/access-section";
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { AnamnesisSection } from "@/components/settings/anamnesis-section";
|
||||
import { SecuritySection } from "@/components/settings/security-section";
|
||||
@@ -60,6 +61,7 @@ const SECTION_COMPONENTS: Record<
|
||||
() => JSX.Element | null
|
||||
> = {
|
||||
account: AccountSection,
|
||||
access: AccessSection,
|
||||
security: SecuritySection,
|
||||
modules: ModulesSection,
|
||||
about: AboutSection,
|
||||
|
||||
@@ -105,6 +105,10 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"security",
|
||||
// v1.36.0 — account sharing: who may open this record, and whose this
|
||||
// account may open. Beside `security`, and distinct from the `sharing`
|
||||
// slug further down (the clinician share LINK).
|
||||
"access",
|
||||
"modules",
|
||||
"integrations",
|
||||
"sources",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { GrantInviteCard } from "@/components/settings/access/grant-invite-card";
|
||||
import { GrantsGivenCard } from "@/components/settings/access/grants-given-card";
|
||||
import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card";
|
||||
import { RecordActivityCard } from "@/components/settings/access/record-activity-card";
|
||||
|
||||
/**
|
||||
* v1.36.0 — Settings → "Shared access".
|
||||
*
|
||||
* One section for one concept: who else can open this health record, and whose
|
||||
* this account may open. Split across two pages it would be two half-answers —
|
||||
* the settings IA rule this repository keeps rediscovering — so both directions
|
||||
* and the activity that follows from them live in one column, in the order
|
||||
* somebody reads them:
|
||||
*
|
||||
* 1. Invite — the act.
|
||||
* 2. Who has access — the standing state, and the way to end it.
|
||||
* 3. What I can open — the same, from the other side.
|
||||
* 4. When it was opened — what actually happened.
|
||||
*
|
||||
* Distinct from Settings → "Health record" → sharing links, which mints a
|
||||
* time-boxed read-only link to a REPORT for a clinician. This section is about
|
||||
* another ACCOUNT on this instance being able to open the live record, which is
|
||||
* a standing relationship rather than a document handed over once.
|
||||
*
|
||||
* Every card here reads and writes routes that refuse while the browser is
|
||||
* acting on somebody else's record, and the section is unreachable in that
|
||||
* state because the whole Settings entry drops out of the nav. Grant management
|
||||
* is the one surface a delegate must have no reach into at all: a delegate who
|
||||
* could invite, widen or transfer would be re-delegating access nobody granted
|
||||
* them.
|
||||
*/
|
||||
export function AccessSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GrantInviteCard />
|
||||
<GrantsGivenCard />
|
||||
<GrantsReceivedCard />
|
||||
<RecordActivityCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInviteGrant } from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — offering somebody read access to this record.
|
||||
*
|
||||
* One field, because there is one decision: who. The access level is not
|
||||
* offered — v1 grants read and only read, and a control whose only setting is
|
||||
* its default is a promise about a future release rather than a choice about
|
||||
* this one.
|
||||
*
|
||||
* The invitation confers nothing until the other person accepts it. The card
|
||||
* says so above the field rather than after the submit, because that is the
|
||||
* part somebody needs to know BEFORE they type a name: nothing has been shared
|
||||
* yet, and nothing will be until the other side agrees.
|
||||
*
|
||||
* Errors are named, not generic. "No account with that name" and "that person
|
||||
* already has access" are different situations with different next steps, and
|
||||
* the server publishes a stable `meta.errorCode` for each precisely so this
|
||||
* card does not have to read prose to tell them apart.
|
||||
*/
|
||||
export function GrantInviteCard() {
|
||||
const { t } = useTranslations();
|
||||
const invite = useInviteGrant();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [invited, setInvited] = useState<string | null>(null);
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (value.length === 0) return;
|
||||
setError(null);
|
||||
setInvited(null);
|
||||
invite.mutate(
|
||||
{ identifier: value, expiresAt: null },
|
||||
{
|
||||
onSuccess: (grant) => {
|
||||
setIdentifier("");
|
||||
setInvited(grant.account.username);
|
||||
},
|
||||
onError: (err) => setError(t(inviteErrorKey(err))),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="grant-invite-card">
|
||||
<SettingsCardHeader
|
||||
icon={UserPlus}
|
||||
title={t("recordSharing.invite.title")}
|
||||
description={t("recordSharing.invite.description")}
|
||||
/>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-invite-identifier">
|
||||
{t("recordSharing.invite.identifierLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="grant-invite-identifier"
|
||||
data-slot="grant-invite-identifier"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder={t("recordSharing.invite.identifierPlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={255}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.invite.identifierHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="grant-invite-submit"
|
||||
disabled={invite.isPending || identifier.trim().length === 0}
|
||||
>
|
||||
{invite.isPending
|
||||
? t("recordSharing.invite.submitting")
|
||||
: t("recordSharing.invite.submit")}
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{invited && (
|
||||
<p role="status" className="text-sm">
|
||||
{t("recordSharing.invite.sent", { name: invited })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a refused invitation.
|
||||
*
|
||||
* Branches on the stable `meta.errorCode` rather than the status alone: a 422
|
||||
* covers both a malformed identifier and an invitation somebody addressed to
|
||||
* themselves, and telling those two apart is the difference between "fix the
|
||||
* field" and "you already have your own record".
|
||||
*/
|
||||
function inviteErrorKey(err: unknown): string {
|
||||
const code =
|
||||
err instanceof ApiError && typeof err.meta?.errorCode === "string"
|
||||
? err.meta.errorCode
|
||||
: null;
|
||||
switch (code) {
|
||||
case "sharing.invite.self":
|
||||
return "recordSharing.invite.errorSelf";
|
||||
case "sharing.invite.duplicate":
|
||||
return "recordSharing.invite.errorDuplicate";
|
||||
case "sharing.invite.invalid":
|
||||
return "recordSharing.invite.errorInvalid";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
return "recordSharing.invite.errorNoAccount";
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 429) {
|
||||
return "recordSharing.invite.errorRateLimited";
|
||||
}
|
||||
return "recordSharing.invite.errorFailed";
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import type { GrantRow, GrantState } from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — one grant, from either side.
|
||||
*
|
||||
* The row's whole job is to say what a person can see and since when, without
|
||||
* anybody having to read a timestamp and work out the answer. Two rules shape
|
||||
* it:
|
||||
*
|
||||
* * **The state comes from the server.** `state` is resolved by the same
|
||||
* function the request resolver's predicate is built on. The row renders
|
||||
* it; it never compares `expiresAt` to the clock, because a panel that
|
||||
* said ACTIVE about a grant the server had stopped honouring would be a
|
||||
* second opinion about somebody's access to a health record.
|
||||
* * **Ended rows stay.** A revoked grant is not gone from the list — the row
|
||||
* IS the consent record, and "who had access, from when to when, and who
|
||||
* ended it" is the question the panel exists to answer. It renders muted
|
||||
* with its end named, rather than disappearing as though it never was.
|
||||
*
|
||||
* The person's name is `text-foreground` (it is the content); every date,
|
||||
* level and last-used note is `text-muted-foreground` (they are meta). The
|
||||
* status badge carries meaning rather than decoration, so it uses the semantic
|
||||
* tokens: active is `success`, an unanswered invitation is `warning`, an ended
|
||||
* one is neutral.
|
||||
*/
|
||||
|
||||
const STATE_VARIANT: Record<
|
||||
GrantState,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
ACTIVE: "default",
|
||||
PENDING: "secondary",
|
||||
EXPIRED: "outline",
|
||||
REVOKED: "outline",
|
||||
};
|
||||
|
||||
export function GrantRowItem({
|
||||
grant,
|
||||
/** What ended it reads differently depending on who is looking. */
|
||||
side,
|
||||
actions,
|
||||
}: {
|
||||
grant: GrantRow;
|
||||
side: "given" | "received";
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const ended = grant.state === "REVOKED" || grant.state === "EXPIRED";
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="grant-row"
|
||||
data-grant-id={grant.id}
|
||||
data-grant-state={grant.state}
|
||||
className="flex flex-wrap items-start justify-between gap-3 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{accountLabel(grant.account)}
|
||||
</span>
|
||||
<Badge variant={STATE_VARIANT[grant.state]}>
|
||||
{t(`recordSharing.state.${stateKey(grant.state)}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{grant.access === "WRITE"
|
||||
? t("recordSharing.row.canWrite")
|
||||
: t("recordSharing.row.readOnly")}
|
||||
</p>
|
||||
<dl className="text-muted-foreground space-y-0.5 text-xs">
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.invited")}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.invitedAt))}</dd>
|
||||
</div>
|
||||
{grant.acceptedAt && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.accepted")}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.acceptedAt))}</dd>
|
||||
</div>
|
||||
)}
|
||||
{grant.expiresAt && !ended && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.expires")}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.expiresAt))}</dd>
|
||||
</div>
|
||||
)}
|
||||
{grant.state === "ACTIVE" && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.lastUsed")}</dt>
|
||||
{/* Absence reads as absence: a grant that has never been
|
||||
exercised says so, rather than showing a blank. */}
|
||||
<dd>
|
||||
{grant.lastUsedAt
|
||||
? fmt.dateTime(new Date(grant.lastUsedAt))
|
||||
: t("recordSharing.row.neverUsed")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{grant.revokedAt && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{endedByKey(grant.revokedBy, side, t)}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.revokedAt))}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center">{actions}</div>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function stateKey(state: GrantState): string {
|
||||
return state.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* "You withdrew it" versus "she handed it back" — the same column, read from
|
||||
* two sides. `revokedBy` is what makes the distinction possible; conflating
|
||||
* them would misreport an ordinary handover as a falling-out.
|
||||
*/
|
||||
function endedByKey(
|
||||
revokedBy: GrantRow["revokedBy"],
|
||||
side: "given" | "received",
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (revokedBy === "GRANTOR") {
|
||||
return side === "given"
|
||||
? t("recordSharing.row.endedByYou")
|
||||
: t("recordSharing.row.endedByOwner");
|
||||
}
|
||||
if (revokedBy === "GRANTEE") {
|
||||
return side === "given"
|
||||
? t("recordSharing.row.handedBack")
|
||||
: t("recordSharing.row.endedByYou");
|
||||
}
|
||||
return t("recordSharing.row.ended");
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Users } from "lucide-react";
|
||||
|
||||
import { GrantRowItem } from "@/components/settings/access/grant-row";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useAccountGrants,
|
||||
useRevokeGrant,
|
||||
type GrantRow,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — who can open this record.
|
||||
*
|
||||
* The compliance surface: at any moment the owner can see who has access, at
|
||||
* what level, since when, and whether it is still being used. Ended grants stay
|
||||
* in the list because the row is the consent record.
|
||||
*
|
||||
* Revoking asks once and then does it. No step-up, no typed confirmation —
|
||||
* reducing access must never be harder than granting it was, and the one party
|
||||
* here whose intent needs no verification is the person closing their own
|
||||
* health record to somebody. The confirm dialog exists only because the click
|
||||
* target sits in a list and a mis-tap should not end an access silently; it
|
||||
* names the person and the act.
|
||||
*/
|
||||
export function GrantsGivenCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useAccountGrants();
|
||||
const revoke = useRevokeGrant();
|
||||
|
||||
const given = data?.given ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="grants-given-card">
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("recordSharing.given.title")}
|
||||
description={t("recordSharing.given.description")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.given.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && given.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<Users className="size-6" />}
|
||||
title={t("recordSharing.given.emptyTitle")}
|
||||
description={t("recordSharing.given.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
{given.length > 0 && (
|
||||
<ul data-slot="grants-given-list" className="divide-y">
|
||||
{given.map((grant) => (
|
||||
<GrantRowItem
|
||||
key={grant.id}
|
||||
grant={grant}
|
||||
side="given"
|
||||
actions={renderRevoke(grant)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
function renderRevoke(grant: GrantRow) {
|
||||
// Only a live grant can be ended. An already-ended row keeps its history
|
||||
// and loses its button, rather than offering an act that would 409.
|
||||
if (grant.state !== "ACTIVE" && grant.state !== "PENDING") return null;
|
||||
const name = accountLabel(grant.account);
|
||||
return (
|
||||
<ConfirmButton
|
||||
slot="grant-revoke"
|
||||
size="sm"
|
||||
label={
|
||||
grant.state === "PENDING"
|
||||
? t("recordSharing.given.withdrawInvite")
|
||||
: t("recordSharing.given.revoke")
|
||||
}
|
||||
title={t("recordSharing.given.revokeTitle", { name })}
|
||||
body={t("recordSharing.given.revokeBody", { name })}
|
||||
confirmLabel={t("recordSharing.given.revokeConfirm")}
|
||||
pending={revoke.isPending}
|
||||
onConfirm={() => revoke.mutate(grant.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { FolderOpen, Loader2 } from "lucide-react";
|
||||
|
||||
import { GrantRowItem } from "@/components/settings/access/grant-row";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useAcceptGrant,
|
||||
useAccountGrants,
|
||||
useRenounceGrant,
|
||||
type GrantRow,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — the records this account has been offered, and the ones it holds.
|
||||
*
|
||||
* The delegate's side. Three affordances, and the order they appear in is the
|
||||
* order somebody meets them:
|
||||
*
|
||||
* * **Accept.** An invitation confers nothing until this button is pressed.
|
||||
* That is the consent handshake, not a formality: being handed read access
|
||||
* to another person's health record is not something to impose silently,
|
||||
* and the acceptance is what the grant row records.
|
||||
* * **Open.** Once accepted, the record can be opened from here as well as
|
||||
* from the user menu — somebody who has just accepted is already looking
|
||||
* at the row and should not have to go hunting for the switcher.
|
||||
* * **Hand back.** The delegate's own way out, attributed as theirs so the
|
||||
* owner's record can tell "I withdrew it" from "he gave it back".
|
||||
*/
|
||||
export function GrantsReceivedCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useAccountGrants();
|
||||
const accept = useAcceptGrant();
|
||||
const renounce = useRenounceGrant();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const received = data?.received ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="grants-received-card">
|
||||
<SettingsCardHeader
|
||||
icon={FolderOpen}
|
||||
title={t("recordSharing.received.title")}
|
||||
description={t("recordSharing.received.description")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.received.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && received.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<FolderOpen className="size-6" />}
|
||||
title={t("recordSharing.received.emptyTitle")}
|
||||
description={t("recordSharing.received.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
{received.length > 0 && (
|
||||
<ul data-slot="grants-received-list" className="divide-y">
|
||||
{received.map((grant) => (
|
||||
<GrantRowItem
|
||||
key={grant.id}
|
||||
grant={grant}
|
||||
side="received"
|
||||
actions={renderActions(grant)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
function renderActions(grant: GrantRow) {
|
||||
if (grant.state === "PENDING") {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
data-slot="grant-accept"
|
||||
disabled={accept.isPending}
|
||||
onClick={() => accept.mutate(grant.id)}
|
||||
>
|
||||
{accept.isPending
|
||||
? t("recordSharing.received.accepting")
|
||||
: t("recordSharing.received.accept")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (grant.state !== "ACTIVE") return null;
|
||||
const name = accountLabel(grant.account);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-slot="grant-open"
|
||||
data-account-id={grant.account.id}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(grant.account.id)}
|
||||
>
|
||||
{t("recordSharing.received.open")}
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
slot="grant-renounce"
|
||||
size="sm"
|
||||
label={t("recordSharing.received.renounce")}
|
||||
title={t("recordSharing.received.renounceTitle", { name })}
|
||||
body={t("recordSharing.received.renounceBody", { name })}
|
||||
confirmLabel={t("recordSharing.received.renounceConfirm")}
|
||||
pending={renounce.isPending}
|
||||
onConfirm={() => renounce.mutate(grant.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { History, Loader2 } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useRecordActivity,
|
||||
type RecordActivityEntry,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — when this record was opened, and by whom.
|
||||
*
|
||||
* ## The card says how far back it can see
|
||||
*
|
||||
* Access rows are purged on a retention schedule (365 days by default, and the
|
||||
* operator can shorten it). A view titled "activity" with no window stated
|
||||
* would imply a completeness it does not have — somebody checking whether
|
||||
* their record was opened last spring would read an empty list as "no" when
|
||||
* the honest answer is "that is further back than this can see". So the bound
|
||||
* is printed under the heading, and it is the number the SERVER resolved from
|
||||
* the same setting the purge job reads, never a constant compiled in here. An
|
||||
* instance running a 90-day window says 90.
|
||||
*
|
||||
* ## What a row is
|
||||
*
|
||||
* One row per delegate per day, with a count — not one row per request. A
|
||||
* hundred identical lines saying "she read your record" is a way of answering
|
||||
* nothing; "she was in your record on Tuesday, 40 times" is the answer somebody
|
||||
* actually wants. Rows the owner performed themselves never appear here: this
|
||||
* is the record of what SOMEBODY ELSE did, and the account's own security
|
||||
* activity has its own surface.
|
||||
*/
|
||||
export function RecordActivityCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useRecordActivity();
|
||||
|
||||
const entries = data?.entries ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="record-activity-card">
|
||||
<SettingsCardHeader
|
||||
icon={History}
|
||||
title={t("recordSharing.activity.title")}
|
||||
description={t("recordSharing.activity.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* The window, stated. Rendered only once the server has said what it
|
||||
is — a placeholder number here would be exactly the invented value
|
||||
the sentence exists to avoid. */}
|
||||
{data && (
|
||||
<p
|
||||
data-slot="record-activity-retention"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.activity.retention", {
|
||||
days: data.retentionDays,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.activity.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && entries.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<History className="size-6" />}
|
||||
title={t("recordSharing.activity.emptyTitle")}
|
||||
description={t("recordSharing.activity.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
{entries.length > 0 && (
|
||||
<ul data-slot="record-activity-list" className="divide-y">
|
||||
{entries.map((entry) => (
|
||||
<ActivityRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityRow({ entry }: { entry: RecordActivityEntry }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
// A deleted account renders as one. The audit column is deliberately not a
|
||||
// foreign key, so the id outlives the person; showing nothing would read as
|
||||
// though the owner had done it themselves.
|
||||
const who = entry.actor
|
||||
? accountLabel(entry.actor)
|
||||
: t("recordSharing.activity.deletedAccount");
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="record-activity-row"
|
||||
className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 py-2"
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{entry.accesses === null
|
||||
? t("recordSharing.activity.acted", { name: who })
|
||||
: t("recordSharing.activity.opened", {
|
||||
name: who,
|
||||
count: entry.accesses,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(new Date(entry.createdAt))}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Thermometer,
|
||||
TrendingUp,
|
||||
User,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -49,6 +50,7 @@ import type { SettingsSectionSlug } from "./section-slugs";
|
||||
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
account: User,
|
||||
security: ShieldCheck,
|
||||
access: UsersRound,
|
||||
modules: Blocks,
|
||||
integrations: Link2,
|
||||
sources: Layers,
|
||||
|
||||
@@ -53,6 +53,14 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// setup (TOTP + security keys), recovery codes, and passkey management. Sits
|
||||
// right after `account` so the two identity surfaces read as neighbours.
|
||||
"security",
|
||||
// v1.36.0 — `access` ("Shared access"): which other accounts on this
|
||||
// instance may open this health record, which records this one may open, and
|
||||
// when they were opened. Sits beside `security` because it answers the same
|
||||
// question those surfaces do — who can get at my account, and what have they
|
||||
// been doing. Distinct from the `sharing` slug below, which is the clinician
|
||||
// share LINK (a time-boxed read-only report handed to somebody outside the
|
||||
// instance); this one is a standing relationship between two accounts on it.
|
||||
"access",
|
||||
// v1.18.0 — `modules` ("Was du trackst") sits right after account: the
|
||||
// single front door for enabling/disabling secondary tracking domains.
|
||||
"modules",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -150,6 +151,16 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
icon: ShieldCheck,
|
||||
group: "account",
|
||||
},
|
||||
// v1.36.0 — who else may open this record, and whose this account may open.
|
||||
// In the Account group beside Security: both answer "who can get at my
|
||||
// account and what have they done", and a person looking for one will look
|
||||
// where the other is.
|
||||
{
|
||||
slug: "access",
|
||||
titleKey: "settings.sections.access.title",
|
||||
icon: UsersRound,
|
||||
group: "account",
|
||||
},
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
// encryption / session / activity pieces into one coherent privacy pane.
|
||||
{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.36.0 — the reads and writes behind the shared-access panel.
|
||||
*
|
||||
* One module for the whole surface, because every mutation here invalidates
|
||||
* the same three things and the standing rule about cache invalidation is
|
||||
* easiest to keep when there is one place that can break it. `grantDependentKeys`
|
||||
* (`@/lib/query-keys`) is that place: the grant list, the owner's activity
|
||||
* feed, and the account payload — the last because `accountAccess` is what
|
||||
* paints the switcher and the banner, and a revoke that refreshed the panel but
|
||||
* not the payload leaves a switcher entry pointing at a record the server has
|
||||
* just closed.
|
||||
*
|
||||
* Nothing in here decides anything. `state` arrives resolved from the same
|
||||
* function the request resolver's own predicate is built on, so the panel
|
||||
* cannot render "active" on a row the server has stopped honouring.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { grantDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The other party to a grant, as far as anyone is told about them. */
|
||||
export interface GrantParty {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export type GrantState = "PENDING" | "ACTIVE" | "EXPIRED" | "REVOKED";
|
||||
|
||||
export interface GrantRow {
|
||||
id: string;
|
||||
account: GrantParty;
|
||||
access: "READ" | "WRITE";
|
||||
state: GrantState;
|
||||
invitedAt: string;
|
||||
acceptedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
revokedBy: "GRANTOR" | "GRANTEE" | null;
|
||||
}
|
||||
|
||||
export interface GrantList {
|
||||
/** Grants this account has offered on its own record. */
|
||||
given: GrantRow[];
|
||||
/** Grants offered to this account on somebody else's. */
|
||||
received: GrantRow[];
|
||||
}
|
||||
|
||||
export interface RecordActivityEntry {
|
||||
id: string;
|
||||
actor: GrantParty | null;
|
||||
action: string;
|
||||
accesses: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecordActivity {
|
||||
entries: RecordActivityEntry[];
|
||||
/**
|
||||
* How far back the feed can see, in days — resolved by the server from the
|
||||
* same setting the purge job reads. The view states it rather than implying
|
||||
* the list is everything that ever happened.
|
||||
*/
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export function useAccountGrants(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.accountGrants(),
|
||||
queryFn: () => apiGet<GrantList>("/api/account/grants"),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecordActivity(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.accountActivity(),
|
||||
queryFn: () => apiGet<RecordActivity>("/api/account/activity"),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh everything a grant transition can have changed.
|
||||
*
|
||||
* Shared by all four mutations rather than written out per hook: the day one
|
||||
* of them forgets the account payload is the day a revoked record keeps its
|
||||
* switcher entry.
|
||||
*/
|
||||
function useInvalidateGrants() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
for (const key of grantDependentKeys) {
|
||||
void queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface InviteInput {
|
||||
identifier: string;
|
||||
/** ISO-8601 with offset, or null for a grant that runs until somebody ends it. */
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export function useInviteGrant() {
|
||||
const invalidate = useInvalidateGrants();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.accountGrantInvite(),
|
||||
mutationFn: (input: InviteInput) =>
|
||||
apiPost<GrantRow>("/api/account/grants", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcceptGrant() {
|
||||
const invalidate = useInvalidateGrants();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.accountGrantAccept(),
|
||||
mutationFn: (grantId: string) =>
|
||||
apiPost<GrantRow>(`/api/account/grants/${grantId}/accept`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeGrant() {
|
||||
const invalidate = useInvalidateGrants();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.accountGrantRevoke(),
|
||||
mutationFn: (grantId: string) =>
|
||||
apiDelete<GrantRow & { sessionsCleared: number }>(
|
||||
`/api/account/grants/${grantId}`,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenounceGrant() {
|
||||
const invalidate = useInvalidateGrants();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.accountGrantRenounce(),
|
||||
mutationFn: (grantId: string) =>
|
||||
apiPost<GrantRow & { sessionsCleared: number }>(
|
||||
`/api/account/grants/${grantId}/renounce`,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user