mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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,58 @@
|
||||
/**
|
||||
* v1.36.0 — the shape of the sharing block, and nothing else.
|
||||
*
|
||||
* Types only, with no imports at all, because both ends read them: the route
|
||||
* that builds the block (`account-access.ts`, which talks to Postgres) and the
|
||||
* client that renders it. Declaring them beside the server code would put a
|
||||
* module that imports Prisma on the client's import graph — erased at compile
|
||||
* today, one careless value export away from being bundled tomorrow. The
|
||||
* boundary is cheaper to keep than to rediscover.
|
||||
*
|
||||
* What these types are FOR is stated once here and holds everywhere they are
|
||||
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
|
||||
* the server has already made; a client that recomputed either from the rest
|
||||
* of the payload would be the second program deciding one person's access.
|
||||
*/
|
||||
|
||||
/** One account this caller may act on. */
|
||||
export interface AccountAccessEntry {
|
||||
/** The account whose record it is — the value `POST /api/account/switch` takes. */
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
/** The grant's level. `"read"` throughout v1. */
|
||||
access: "read" | "write";
|
||||
/** May this caller change that record. Resolved server-side; `false` in v1. */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
/** The `accountAccess` block on `GET /api/auth/me`. */
|
||||
export interface AccountAccess {
|
||||
/** Every account this caller may open, newest grant first. */
|
||||
accounts: AccountAccessEntry[];
|
||||
/**
|
||||
* The record this browser is inside right now, resolved to a full entry —
|
||||
* not an id the client has to look up. Null when it is in its own record,
|
||||
* and always null on the Bearer transport.
|
||||
*/
|
||||
active: AccountAccessEntry | null;
|
||||
/** Is there anywhere to switch to. */
|
||||
canSwitch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to put in front of a person, given what the payload published.
|
||||
*
|
||||
* `displayName` when the account set one, the username otherwise. This is a
|
||||
* presentation fallback and not a derivation of anything the server decided —
|
||||
* both values are published, and which of the two reads better is the client's
|
||||
* business. Kept here so the banner, the switcher and the sharing panel cannot
|
||||
* disagree about what to call somebody.
|
||||
*/
|
||||
export function accountLabel(entry: {
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
}): string {
|
||||
const named = entry.displayName?.trim();
|
||||
return named && named.length > 0 ? named : entry.username;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* v1.36.0 — what a caller may do with account sharing, resolved server-side.
|
||||
*
|
||||
* This module answers three questions for one caller, once per `/api/auth/me`:
|
||||
* which records may I open, which one am I inside right now, and what may I do
|
||||
* in it. Every answer is a VALUE, not an input to a client-side calculation.
|
||||
*
|
||||
* That is the whole design of this file and it is worth stating plainly,
|
||||
* because the tempting alternative is one line shorter: publish the grant rows
|
||||
* and let the client work out what they mean. Then two programs decide one
|
||||
* person's access — the resolver in `src/lib/api-handler.ts`, and whichever
|
||||
* client is being read today — and they answer differently the first time an
|
||||
* expiry, a revocation or a second access level appears. The client renders
|
||||
* `canSwitch` and `canWrite`; it never computes them, and there is nothing in
|
||||
* the payload it could compute them from.
|
||||
*
|
||||
* `canWrite` is `false` for every entry in v1, because `inviteGrant` writes
|
||||
* READ and only READ. It is resolved here through {@link grantAllows} against
|
||||
* the real row rather than hardcoded, so the day a WRITE grant exists the
|
||||
* answer changes without this file being edited — and, more importantly, so
|
||||
* that nobody has to remember that it should.
|
||||
*
|
||||
* What is deliberately NOT published: the other account's avatar. The avatar
|
||||
* bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the
|
||||
* caller's own), so a URL here would resolve to a 403 and paint a broken
|
||||
* image. Opening that route to a delegate is a classification decision about
|
||||
* a route, not a decision the account payload gets to make on the side.
|
||||
* Clients paint the initials fallback they already have.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type {
|
||||
AccountAccess,
|
||||
AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
|
||||
|
||||
/**
|
||||
* Resolve the sharing block for one caller.
|
||||
*
|
||||
* The block is always present, even for an account with no grants in either
|
||||
* direction: an empty `accounts` array with `canSwitch: false` says "nobody has
|
||||
* shared anything with you", which is a different statement from a missing
|
||||
* field (which says "this server does not know about sharing"). Absence reads
|
||||
* as absence.
|
||||
*
|
||||
* Takes the whole auth context rather than a pair of ids, so the carrier column
|
||||
* is named here and in no route file — the boundary guard in
|
||||
* `src/__tests__/acting-account-boundary-guard.test.ts` keeps that list short
|
||||
* on purpose, and a field plucked out at every call site is how such a list
|
||||
* grows one entry per surface.
|
||||
*
|
||||
* The session's stamp is deliberately NOT trusted to mean access: an entry
|
||||
* appears in `active` only if the same pass that
|
||||
* built `accounts` admitted it. So a stamp left behind by a grant that lapsed
|
||||
* between two requests reads as "not switched" here, which is exactly what the
|
||||
* request resolver will independently decide about the next delegated read.
|
||||
* The client's job on seeing that mismatch is to clear the stamp, not to
|
||||
* reason about it.
|
||||
*/
|
||||
export async function resolveAccountAccess(auth: {
|
||||
user: { id: string };
|
||||
session: { actingAsUserId?: string | null };
|
||||
}): Promise<AccountAccess> {
|
||||
const actorId = auth.user.id;
|
||||
const stamped = auth.session.actingAsUserId ?? null;
|
||||
const now = new Date();
|
||||
|
||||
// Narrow in SQL on the one condition the partial unique index makes cheap,
|
||||
// and let the state machine decide the rest — the same split
|
||||
// `findActiveGrant` uses, and for the same reason: a SQL predicate spelling
|
||||
// out "accepted and not expired" beside a TypeScript one is two places
|
||||
// deciding one question.
|
||||
const rows = await prisma.accountGrant.findMany({
|
||||
where: { granteeId: actorId, revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
grantor: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const accounts: AccountAccessEntry[] = rows
|
||||
.filter((grant) => isGrantActive(grant, now))
|
||||
.map((grant) => ({
|
||||
accountId: grant.grantor.id,
|
||||
username: grant.grantor.username,
|
||||
displayName: grant.grantor.displayName,
|
||||
access: grant.access === "WRITE" ? "write" : "read",
|
||||
canWrite: grantAllows(grant, "write", now),
|
||||
}));
|
||||
|
||||
const active =
|
||||
stamped === null
|
||||
? null
|
||||
: (accounts.find((a) => a.accountId === stamped) ?? null);
|
||||
|
||||
return { accounts, active, canSwitch: accounts.length > 0 };
|
||||
}
|
||||
Reference in new issue
Block a user