mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
This commit is contained in:
60 files changed
+5648
-174
No files matched your search
@@ -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(),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user