mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(() => ({ catch: () => undefined })),
|
||||
},
|
||||
user: { findUnique: vi.fn() },
|
||||
accountGrant: { findMany: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -165,6 +166,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
accessTokensRevoked: 0,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
pendingInvitesRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
@@ -205,6 +207,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
accessTokensRevoked: 1,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
pendingInvitesRevoked: 0,
|
||||
});
|
||||
|
||||
const res = await DELETE(del());
|
||||
@@ -219,6 +222,53 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign out everywhere and record sharing", () => {
|
||||
it("reports withdrawn invitations and lists the accepted grants it kept", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({
|
||||
sessionsRevoked: 1,
|
||||
accessTokensRevoked: 0,
|
||||
connectorsRevoked: 0,
|
||||
shareLinksRevoked: 0,
|
||||
pendingInvitesRevoked: 2,
|
||||
});
|
||||
const party = { id: "user-2", username: "carer", displayName: null };
|
||||
const live = {
|
||||
id: "grant-live",
|
||||
access: "READ",
|
||||
acceptedAt: new Date("2026-01-01"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
grantee: party,
|
||||
};
|
||||
const lapsed = {
|
||||
...live,
|
||||
id: "grant-lapsed",
|
||||
expiresAt: new Date("2026-02-01"),
|
||||
};
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
live,
|
||||
lapsed,
|
||||
] as never);
|
||||
|
||||
const res = await DELETE(del());
|
||||
const body = await res.json();
|
||||
expect(body.data.pendingInvitesRevoked).toBe(2);
|
||||
// The lapsed one confers nothing any more and is not listed.
|
||||
expect(body.data.grantsKept).toEqual([
|
||||
{ id: "grant-live", account: party, access: "READ" },
|
||||
]);
|
||||
expect(prisma.accountGrant.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
grantorId: "user-1",
|
||||
revokedAt: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions/[id]", () => {
|
||||
function ctx(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* connection and token, and (unless
|
||||
* `?keepShareLinks=1`) every clinician share
|
||||
* link, keeping the caller's current
|
||||
* credential. Closes #64.
|
||||
* credential. Closes #64. Also withdraws
|
||||
* record-sharing invitations nobody has
|
||||
* accepted yet, and lists the accepted
|
||||
* grants it left standing (`grantsKept`).
|
||||
*
|
||||
* v1.23 — the user-facing session/device-management surface. Distinct from
|
||||
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
|
||||
@@ -34,6 +37,8 @@ import {
|
||||
type CurrentCredential,
|
||||
} from "@/lib/auth/session";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
|
||||
import { isGrantActive } from "@/lib/sharing/grants";
|
||||
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -131,11 +136,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
pendingInvitesRevoked,
|
||||
} = await destroyOtherSessions(user.id, current, {
|
||||
reach: "everything",
|
||||
keepShareLinks,
|
||||
});
|
||||
|
||||
// The accepted grants this account gave, still live. Not ended here: each is
|
||||
// a person the owner chose, often a carer who still needs the record, and a
|
||||
// button labelled "sign out" must not quietly cut them off. They are listed
|
||||
// instead, so the person who just signed everything out can see who can
|
||||
// still read the record and end any of them with one click.
|
||||
const now = new Date();
|
||||
const grantsKept = (
|
||||
await prisma.accountGrant.findMany({
|
||||
where: {
|
||||
grantorId: user.id,
|
||||
acceptedAt: { not: null },
|
||||
revokedAt: null,
|
||||
},
|
||||
orderBy: { acceptedAt: "desc" },
|
||||
include: { grantee: { select: GRANT_PARTY_SELECT } },
|
||||
})
|
||||
)
|
||||
.filter((g) => isGrantActive(g, now))
|
||||
.map((g) => ({ id: g.id, account: g.grantee, access: g.access }));
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -144,6 +170,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
pendingInvitesRevoked,
|
||||
grantsKept: grantsKept.length,
|
||||
keepShareLinks,
|
||||
},
|
||||
});
|
||||
@@ -155,6 +183,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
access_tokens_revoked: accessTokensRevoked,
|
||||
connectors_revoked: connectorsRevoked,
|
||||
share_links_revoked: shareLinksRevoked,
|
||||
pending_invites_revoked: pendingInvitesRevoked,
|
||||
grants_kept: grantsKept.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -163,6 +193,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
accessTokensRevoked,
|
||||
connectorsRevoked,
|
||||
shareLinksRevoked,
|
||||
pendingInvitesRevoked,
|
||||
grantsKept,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user