Merge pull request #690 from MBombeck/work/sharing-client

Account sharing, the part people touch
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 17:13:30 +02:00
commit 210380f46d
60 files changed
+5648 -174

No files matched your search

+128
View File
@@ -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(),
});
});
+60 -29
View File
@@ -1,5 +1,23 @@
/**
* `GET /api/auth/me` — the account payload, and the one surface that keeps
* answering while the browser is acting on somebody else's record.
*
* v1.36.0 made it an ACTOR surface (`requireActorAuth`). It has to be: the
* switcher, the banner naming whose record is open, and the way back out all
* read this payload, and all three are about the person at the keyboard, not
* the record they are looking at. Under a bare `requireAuth()` the whole app
* shell would 403 the moment a switch was on.
*
* So every field below is still the CALLER's own — their preferences, their
* modules, their identity — and the one field about the switch,
* `accountAccess`, says so explicitly. Nothing here ever renders the owner's
* data; a delegate reading their own display preferences while inside
* somebody else's record is the correct behaviour and the reason the mode
* exists (design §4: locale, theme and layout belong to the person, not the
* record).
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
@@ -12,11 +30,13 @@ import {
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const auth = await requireActorAuth();
const { user } = auth;
annotate({ action: { name: "auth.me" } });
@@ -27,33 +47,38 @@ export const GET = apiHandler(async () => {
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
getOperatorModuleAvailability(),
]);
const [, , cycleProfile, modules, moduleAvailability, accountAccess] =
await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
getOperatorModuleAvailability(),
// v1.36.0 — which records this caller may open, which one they are
// inside, and what they may do there. Every value resolved server-side;
// see the module docblock for why none of it is left to the client.
resolveAccountAccess(auth),
]);
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
@@ -142,5 +167,11 @@ export const GET = apiHandler(async () => {
// this to show a "disabled server-wide" read-only row; everywhere else
// the already-AND-ed `modules` map is the single gate to read.
moduleAvailability,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
// directly. Always present — an account with no grants gets an empty list,
// not a missing field.
accountAccess,
});
});