fix(sharing): invitations need a fresh proof and end with sign out everywhere

Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.

Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:34:21 +02:00
1 parent a4d27c618e
commit a294d7f684
22 files changed
+585 -65

No files matched your search

@@ -70,15 +70,13 @@ describe("grant invitation lapse date", () => {
),
"utf8",
);
const onError = source.slice(
source.indexOf("onError: (err) => setError"),
source.indexOf(
" );\n };",
source.indexOf("onError: (err) => setError"),
),
);
// The failure arm of the submit: everything from its `catch` to the end
// of the handler.
const start = source.indexOf("} catch (err) {");
expect(start).toBeGreaterThan(-1);
const onError = source.slice(start, source.indexOf("\n };", start));
expect(onError).toContain("setError(t(inviteErrorKey(err)))");
expect(onError).toContain("t(inviteErrorKey(err))");
expect(onError).not.toContain("setIdentifier");
expect(onError).not.toContain("setExpiresOn");
expect(source).toContain('role="alert"');
@@ -18,6 +18,10 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useInviteGrant } from "@/lib/queries/use-account-grants";
import {
ReproofCancelledError,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
@@ -79,6 +83,7 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
export function GrantInviteCard() {
const { t } = useTranslations();
const invite = useInviteGrant();
const recentProof = useRecentProof();
const ownerTimezone = useInvitationOwnerTimezone();
const [identifier, setIdentifier] = useState("");
const [expiresOn, setExpiresOn] = useState("");
@@ -118,31 +123,40 @@ export function GrantInviteCard() {
setError(null);
};
const submit = (event: React.FormEvent) => {
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const value = identifier.trim();
if (value.length === 0 || choice.blocked) return;
setError(null);
setInvited(null);
invite.mutate(
{
identifier: value,
access,
scope: choice.scope,
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
},
{
onSuccess: (grant) => {
setIdentifier("");
setExpiresOn("");
setAccess("READ");
setNarrowed(null);
setSections([]);
setInvited(grant.account.username);
},
onError: (err) => setError(t(inviteErrorKey(err))),
},
);
// Offering access hands somebody a standing way into the record, so the
// server asks for a fresh proof unless the session signed in or re-proved
// within five minutes; the dialog collects it and the invitation is sent
// again.
try {
const grant = await recentProof.run(() =>
invite.mutateAsync({
identifier: value,
access,
scope: choice.scope,
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
}),
);
setIdentifier("");
setExpiresOn("");
setAccess("READ");
setNarrowed(null);
setSections([]);
setInvited(grant.account.username);
} catch (err) {
if (err instanceof ReproofCancelledError) return;
setError(
err instanceof ApiError &&
err.meta?.errorCode === "auth.reproof.sign_in_again"
? err.message
: t(inviteErrorKey(err)),
);
}
};
return (
@@ -159,7 +173,7 @@ export function GrantInviteCard() {
"recordSharing.invite.needsAccount",
)}`}
/>
<form onSubmit={submit} className="space-y-3">
<form onSubmit={(event) => void submit(event)} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="grant-invite-identifier">
{t("recordSharing.invite.identifierLabel")}
@@ -373,6 +387,7 @@ export function GrantInviteCard() {
</Button>
</SettingsCardActions>
</form>
{recentProof.dialog}
</SettingsCard>
);
}
@@ -16,6 +16,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -23,8 +24,22 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
import { useRevokeGrant } from "@/lib/queries/use-account-grants";
import { cn } from "@/lib/utils";
/** An accepted grant sign-out-everywhere left standing (`grantsKept`). */
interface KeptGrant {
id: string;
account: { id: string; username: string; displayName: string | null };
access: "READ" | "WRITE" | "MANAGE";
}
const ACCESS_LABEL_KEY: Record<KeptGrant["access"], string> = {
READ: "recordSharing.invite.accessReadLabel",
WRITE: "recordSharing.invite.accessWriteLabel",
MANAGE: "recordSharing.invite.accessManageLabel",
};
interface SessionRow {
id: string;
device: string;
@@ -54,6 +69,12 @@ export function SecuritySessionsCard({
// state; nothing is persisted across reloads.
const [open, setOpen] = useState(false);
const regionId = useId();
// The people who can still read this record after "sign out everywhere".
// Their access is not ended by it (a carer who still needs the record would
// be cut off by a button labelled "sign out"), so they are shown here with
// a one-click end instead. Held only until the card unmounts.
const [keptGrants, setKeptGrants] = useState<KeptGrant[]>([]);
const revokeGrant = useRevokeGrant();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.sessions(),
@@ -75,17 +96,32 @@ export function SecuritySessionsCard({
const revokeOthers = useMutation({
mutationFn: () =>
apiDelete<{ sessionsRevoked: number }>(
apiDelete<{
sessionsRevoked: number;
pendingInvitesRevoked?: number;
grantsKept?: KeptGrant[];
}>(
endShareLinks
? "/api/auth/me/sessions"
: "/api/auth/me/sessions?keepShareLinks=1",
),
onSuccess: (res) => {
const invites = res?.pendingInvitesRevoked ?? 0;
setStatus(
t("settings.security.signOutEverywhereDone", {
count: res?.sessionsRevoked ?? 0,
}),
[
t("settings.security.signOutEverywhereDone", {
count: res?.sessionsRevoked ?? 0,
}),
invites > 0
? t("settings.security.signOutEverywhereInvitesDone", {
count: invites,
})
: null,
]
.filter(Boolean)
.join(" "),
);
setKeptGrants(res?.grantsKept ?? []);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
@@ -206,17 +242,24 @@ export function SecuritySessionsCard({
onConfirm={() => revokeOthers.mutate()}
pending={revokeOthers.isPending}
extra={
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-testid="sign-out-everywhere-share-links"
checked={endShareLinks}
onCheckedChange={(next) => setEndShareLinks(next === true)}
className="mt-0.5"
/>
<span>
{t("settings.security.signOutEverywhereShareLinks")}
</span>
</label>
<div className="space-y-3">
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-testid="sign-out-everywhere-share-links"
checked={endShareLinks}
onCheckedChange={(next) =>
setEndShareLinks(next === true)
}
className="mt-0.5"
/>
<span>
{t("settings.security.signOutEverywhereShareLinks")}
</span>
</label>
<p className="text-sm">
{t("settings.security.signOutEverywhereInvites")}
</p>
</div>
}
/>
</SettingsCardActions>
@@ -227,6 +270,57 @@ export function SecuritySessionsCard({
{status}
</p>
)}
{keptGrants.length > 0 && (
<div
data-slot="sign-out-everywhere-grants-kept"
className="space-y-2 border-t pt-4"
>
<p className="text-sm font-medium">
{t("settings.security.grantsKeptTitle")}
</p>
<p className="text-muted-foreground text-sm">
{t("settings.security.grantsKeptBody")}
</p>
<ul className="divide-y">
{keptGrants.map((grant) => (
<li
key={grant.id}
className="flex items-center justify-between gap-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm">
{grant.account.displayName ?? grant.account.username}
</p>
<p className="text-muted-foreground text-xs">
{t(ACCESS_LABEL_KEY[grant.access])}
</p>
</div>
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={revokeGrant.isPending}
onClick={() =>
revokeGrant.mutate(grant.id, {
onSuccess: () =>
setKeptGrants((current) =>
current.filter((g) => g.id !== grant.id),
),
onError: () =>
setStatus(
t("recordSharing.actionError.revokeFailed"),
),
})
}
>
{t("recordSharing.given.revoke")}
</Button>
</li>
))}
</ul>
</div>
)}
</div>
</SettingsCard>
);