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:
Marc-André Bombeck committed 2026-08-02 14:26:39 +02:00
1 parent dd3d5646de
commit 5199de78af
20 files changed
+1620

No files matched your search

@@ -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,
+8
View File
@@ -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.
{