mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user