mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix: preserve account access compatibility
This commit is contained in:
1 parent
e4e773d543
commit
4923db6464
23 files changed
+288
-14
No files matched your search
+18
-3
@@ -36712,12 +36712,20 @@ components:
|
||||
banner can name the person without joining two fields. Null when the caller is in their own record, and
|
||||
ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses
|
||||
one."
|
||||
recordKind:
|
||||
type: string
|
||||
enum:
|
||||
- self
|
||||
- shared
|
||||
- managed
|
||||
description: The server-resolved kind of the record currently in view. `self` applies when `active` is null.
|
||||
canSwitch:
|
||||
type: boolean
|
||||
description: Whether there is anywhere to switch to. Bind it directly.
|
||||
required:
|
||||
- accounts
|
||||
- active
|
||||
- recordKind
|
||||
- canSwitch
|
||||
additionalProperties: false
|
||||
description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server
|
||||
@@ -36743,9 +36751,8 @@ components:
|
||||
enum:
|
||||
- read
|
||||
- write
|
||||
- manage
|
||||
description: The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one
|
||||
server-side expression — and kept because shipped clients read it. New code reads `level`.
|
||||
description: The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads
|
||||
canonical `level`.
|
||||
level:
|
||||
type: string
|
||||
enum:
|
||||
@@ -36755,6 +36762,13 @@ components:
|
||||
description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable
|
||||
surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management),
|
||||
which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.
|
||||
recordKind:
|
||||
type: string
|
||||
enum:
|
||||
- self
|
||||
- shared
|
||||
- managed
|
||||
description: Server-resolved presentation metadata for the record. It is never accepted as an authorization input.
|
||||
sections:
|
||||
anyOf:
|
||||
- type: array
|
||||
@@ -36778,6 +36792,7 @@ components:
|
||||
- displayName
|
||||
- access
|
||||
- level
|
||||
- recordKind
|
||||
- sections
|
||||
- canWrite
|
||||
additionalProperties: false
|
||||
|
||||
@@ -9520,6 +9520,8 @@
|
||||
"description": "Akten, die andere mit dir geteilt haben.",
|
||||
"levelRead": "Darf lesen",
|
||||
"levelWrite": "Darf lesen und hinzufügen",
|
||||
"kindShared": "Geteilte Akte",
|
||||
"kindManaged": "Verwaltetes Profil",
|
||||
"open": "Akte von {name} öffnen"
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -9520,6 +9520,8 @@
|
||||
"description": "Records other people have shared with you.",
|
||||
"levelRead": "Can view",
|
||||
"levelWrite": "Can view and add",
|
||||
"kindShared": "Shared record",
|
||||
"kindManaged": "Managed profile",
|
||||
"open": "Open {name}'s record"
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -9520,6 +9520,8 @@
|
||||
"description": "Historiales que otras personas han compartido contigo.",
|
||||
"levelRead": "Puede ver",
|
||||
"levelWrite": "Puede ver y añadir",
|
||||
"kindShared": "Registro compartido",
|
||||
"kindManaged": "Perfil administrado",
|
||||
"open": "Abrir el historial de {name}"
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -9520,6 +9520,8 @@
|
||||
"description": "Les dossiers que d'autres personnes partagent avec vous.",
|
||||
"levelRead": "Peut consulter",
|
||||
"levelWrite": "Peut consulter et ajouter",
|
||||
"kindShared": "Dossier partagé",
|
||||
"kindManaged": "Profil géré",
|
||||
"open": "Ouvrir le dossier de {name}"
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -9520,6 +9520,8 @@
|
||||
"description": "Cartelle che altre persone hanno condiviso con te.",
|
||||
"levelRead": "Può consultare",
|
||||
"levelWrite": "Può consultare e aggiungere",
|
||||
"kindShared": "Cartella condivisa",
|
||||
"kindManaged": "Profilo gestito",
|
||||
"open": "Apri la cartella di {name}"
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -9520,6 +9520,8 @@
|
||||
"description": "Karty, które udostępniły ci inne osoby.",
|
||||
"levelRead": "Może przeglądać",
|
||||
"levelWrite": "Może przeglądać i dodawać",
|
||||
"kindShared": "Udostępniona karta",
|
||||
"kindManaged": "Zarządzany profil",
|
||||
"open": "Otwórz kartę użytkownika {name}"
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -80,6 +80,19 @@ const NOT_A_CONSUMER = new Set([
|
||||
join(SRC, "app", "api", "auth", "me", "route.ts"),
|
||||
]);
|
||||
|
||||
const ACCOUNT_ENTRY_CONSUMER = join(
|
||||
SRC,
|
||||
"components",
|
||||
"dashboard",
|
||||
"looking-after-card.tsx",
|
||||
);
|
||||
const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts");
|
||||
const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [
|
||||
"level",
|
||||
"sections",
|
||||
"recordKind",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fields with no client reader, each with the reason it is nonetheless
|
||||
* correct for the payload to carry it. An entry here is a claim; write one
|
||||
@@ -192,4 +205,24 @@ describe("account payload consumer guard", () => {
|
||||
expect(reason.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it("has non-zero entry and active-record readers for additive access fields", () => {
|
||||
const entrySource = stripComments(
|
||||
readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"),
|
||||
);
|
||||
const activeSource = stripComments(
|
||||
readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"),
|
||||
);
|
||||
|
||||
const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce(
|
||||
(count, field) => {
|
||||
expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`));
|
||||
expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
|
||||
return count + 2;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
expect(readerCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ const OWNER = {
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const OWNER = {
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const OWNER = {
|
||||
access: "write" as const,
|
||||
level: "write" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const GRANDMOTHER = {
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
const FATHER = {
|
||||
@@ -36,6 +37,7 @@ const FATHER = {
|
||||
access: "write" as const,
|
||||
level: "write" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -97,6 +97,12 @@ export function LookingAfterCard() {
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{levelLabel(entry, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{recordKindLabel(entry, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{scopeLabel(entry, t)}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
@@ -128,7 +134,34 @@ function levelLabel(
|
||||
entry: AccountAccessEntry,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (entry.level === "manage") {
|
||||
return t("recordSharing.row.canManage");
|
||||
}
|
||||
return entry.canWrite
|
||||
? t("recordSharing.lookingAfter.levelWrite")
|
||||
: t("recordSharing.lookingAfter.levelRead");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
entry: AccountAccessEntry,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return entry.recordKind === "managed"
|
||||
? t("recordSharing.lookingAfter.kindManaged")
|
||||
: t("recordSharing.lookingAfter.kindShared");
|
||||
}
|
||||
|
||||
function scopeLabel(
|
||||
entry: AccountAccessEntry,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (entry.sections === null) {
|
||||
return t("recordSharing.row.entireRecord");
|
||||
}
|
||||
if (entry.sections.length === 0) {
|
||||
return t("recordSharing.row.noSections");
|
||||
}
|
||||
return entry.sections
|
||||
.map((domain) => t(`recordSharing.section.${domain}.label`))
|
||||
.join(", ");
|
||||
}
|
||||
@@ -117,6 +117,7 @@ const OWNER_READ: AccountAccessEntry = {
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
const OWNER_WRITE: AccountAccessEntry = {
|
||||
|
||||
@@ -59,6 +59,7 @@ const OWNER = {
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const READ_ONLY: AccountAccessEntry = {
|
||||
access: "read",
|
||||
level: "read",
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
@@ -39,7 +40,7 @@ const WRITABLE: AccountAccessEntry = {
|
||||
|
||||
const MANAGING: AccountAccessEntry = {
|
||||
...READ_ONLY,
|
||||
access: "manage",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
};
|
||||
@@ -64,6 +65,7 @@ describe("resolveRecordCapabilities", () => {
|
||||
// apart from a real one.
|
||||
level: null,
|
||||
sections: null,
|
||||
recordKind: "self",
|
||||
});
|
||||
expect(resolveRecordCapabilities(undefined)).toEqual(
|
||||
resolveRecordCapabilities(null),
|
||||
@@ -78,6 +80,7 @@ describe("resolveRecordCapabilities", () => {
|
||||
canManage: false,
|
||||
level: "read",
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +94,7 @@ describe("resolveRecordCapabilities", () => {
|
||||
canManage: false,
|
||||
level: "write",
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,6 +125,7 @@ describe("resolveRecordCapabilities", () => {
|
||||
canManage: false,
|
||||
level: "manage",
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
export const NO_ACCOUNT_ACCESS: AccountAccess = {
|
||||
accounts: [],
|
||||
active: null,
|
||||
recordKind: "self",
|
||||
canSwitch: false,
|
||||
};
|
||||
|
||||
@@ -347,6 +348,12 @@ function coerceAccountAccess(value: unknown): AccountAccess {
|
||||
return {
|
||||
accounts: block.accounts,
|
||||
active: block.active ?? null,
|
||||
recordKind:
|
||||
block.recordKind === "self" ||
|
||||
block.recordKind === "shared" ||
|
||||
block.recordKind === "managed"
|
||||
? block.recordKind
|
||||
: "self",
|
||||
canSwitch: block.canSwitch === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface RecordCapabilities {
|
||||
* nothing; it is a real answer and reads as one.
|
||||
*/
|
||||
sections: AccountAccessEntry["sections"];
|
||||
/** The server-resolved kind of the record currently in view. */
|
||||
recordKind: AccountAccessEntry["recordKind"] | "self";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +124,7 @@ export function resolveRecordCapabilities(
|
||||
// to tell those apart would have to guess.
|
||||
level: null,
|
||||
sections: null,
|
||||
recordKind: "self",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -131,6 +134,7 @@ export function resolveRecordCapabilities(
|
||||
canManage: false,
|
||||
level: active.level,
|
||||
sections: active.sections,
|
||||
recordKind: active.recordKind,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const OWNER = {
|
||||
access: "write" as const,
|
||||
level: "write" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -220,15 +220,20 @@ const accountAccessEntry = z
|
||||
username: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
access: z
|
||||
.enum(["read", "write", "manage"])
|
||||
.enum(["read", "write"])
|
||||
.describe(
|
||||
"The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one server-side expression — and kept because shipped clients read it. New code reads `level`.",
|
||||
"The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads canonical `level`.",
|
||||
),
|
||||
level: z
|
||||
.enum(["read", "write", "manage"])
|
||||
.describe(
|
||||
"The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.",
|
||||
),
|
||||
recordKind: z
|
||||
.enum(["self", "shared", "managed"])
|
||||
.describe(
|
||||
"Server-resolved presentation metadata for the record. It is never accepted as an authorization input.",
|
||||
),
|
||||
sections: z
|
||||
.array(shareSection)
|
||||
.nullable()
|
||||
@@ -257,6 +262,11 @@ export const accountAccessBlock = z
|
||||
.describe(
|
||||
"The record this session is inside right now, resolved to a full entry rather than an id to look up — so a banner can name the person without joining two fields. Null when the caller is in their own record, and ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses one.",
|
||||
),
|
||||
recordKind: z
|
||||
.enum(["self", "shared", "managed"])
|
||||
.describe(
|
||||
"The server-resolved kind of the record currently in view. `self` applies when `active` is null.",
|
||||
),
|
||||
canSwitch: z
|
||||
.boolean()
|
||||
.describe("Whether there is anywhere to switch to. Bind it directly."),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
accountGrant: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveAccountAccess } from "../account-access";
|
||||
import { decodeLegacyWholeRecordAccess } from "../account-access-view";
|
||||
import { LEGACY_ACCOUNT_PAYLOADS } from "../../../../tests/fixtures/v137/legacy-account-payloads";
|
||||
|
||||
function activeGrant({
|
||||
access,
|
||||
managedProfileAt = null,
|
||||
}: {
|
||||
access: "READ" | "WRITE" | "MANAGE";
|
||||
managedProfileAt?: Date | null;
|
||||
}) {
|
||||
return {
|
||||
id: `grant-${access.toLowerCase()}`,
|
||||
access,
|
||||
scopeJson: null,
|
||||
acceptedAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
grantor: {
|
||||
id: "record-owner",
|
||||
username: "record-owner",
|
||||
displayName: "Record owner",
|
||||
managedProfileAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveAccountAccess", () => {
|
||||
it("keeps MANAGE compatible with legacy access while publishing its canonical level", async () => {
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
activeGrant({
|
||||
access: "MANAGE",
|
||||
managedProfileAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: { actingAsUserId: "record-owner" },
|
||||
});
|
||||
|
||||
expect(access.accounts).toEqual([
|
||||
expect.objectContaining({
|
||||
access: "write",
|
||||
level: "manage",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
recordKind: "managed",
|
||||
}),
|
||||
]);
|
||||
expect(access.active).toEqual(
|
||||
expect.objectContaining({
|
||||
level: "manage",
|
||||
sections: null,
|
||||
recordKind: "managed",
|
||||
}),
|
||||
);
|
||||
expect(access.recordKind).toBe("managed");
|
||||
});
|
||||
|
||||
it("resolves an ordinary shared record on the server", async () => {
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
activeGrant({ access: "READ" }),
|
||||
] as never);
|
||||
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: {},
|
||||
});
|
||||
|
||||
expect(access.accounts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
access: "read",
|
||||
level: "read",
|
||||
recordKind: "shared",
|
||||
}),
|
||||
);
|
||||
expect(access.recordKind).toBe("self");
|
||||
});
|
||||
|
||||
it("keeps whole-record legacy payloads decodable and scoped payloads closed", () => {
|
||||
for (const fixture of LEGACY_ACCOUNT_PAYLOADS) {
|
||||
const decoded = decodeLegacyWholeRecordAccess(fixture);
|
||||
expect(decoded === null ? "deny" : "allow", fixture.name).toBe(
|
||||
fixture.legacyDecoder,
|
||||
);
|
||||
if (fixture.legacyDecoder === "allow") {
|
||||
expect(decoded).toBe(fixture.access);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,26 @@ import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
/** What a grant lets its holder do, resolved. */
|
||||
export type AccountAccessLevel = "read" | "write" | "manage";
|
||||
|
||||
/** The two values published by the original account-access contract. */
|
||||
export type LegacyAccountAccessLevel = "read" | "write";
|
||||
|
||||
/** The server-resolved kind of record a presentation is naming. */
|
||||
export type AccountRecordKind = "self" | "shared" | "managed";
|
||||
|
||||
/**
|
||||
* Read the portion of an account-access entry that an older whole-record
|
||||
* client understands. A scoped entry is refused rather than guessed at.
|
||||
*/
|
||||
export function decodeLegacyWholeRecordAccess(
|
||||
value: unknown,
|
||||
): LegacyAccountAccessLevel | null {
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
const entry = value as { access?: unknown; sections?: unknown };
|
||||
if (entry.access !== "read" && entry.access !== "write") return null;
|
||||
if (entry.sections !== undefined && entry.sections !== null) return null;
|
||||
return entry.access;
|
||||
}
|
||||
|
||||
/** One account this caller may act on. */
|
||||
export interface AccountAccessEntry {
|
||||
/** The account whose record it is — the value `POST /api/account/switch` takes. */
|
||||
@@ -37,13 +57,18 @@ export interface AccountAccessEntry {
|
||||
* assigned from one expression in `account-access.ts`, so they cannot come
|
||||
* apart; `level` is the name to write new code against.
|
||||
*/
|
||||
access: AccountAccessLevel;
|
||||
access: LegacyAccountAccessLevel;
|
||||
/**
|
||||
* v1.37.0 — the same resolved level, under the name the three-level
|
||||
* contract speaks. Read it; never derive it from `canWrite`, and never
|
||||
* derive `canWrite` from it.
|
||||
*/
|
||||
level: AccountAccessLevel;
|
||||
/**
|
||||
* What kind of record this is. This is presentation metadata only; it is
|
||||
* resolved by the server and never accepted as an authorization input.
|
||||
*/
|
||||
recordKind: AccountRecordKind;
|
||||
/**
|
||||
* v1.37.0 — which sections of the record this grant opens, or `null` for
|
||||
* the entire record.
|
||||
@@ -81,6 +106,8 @@ export interface AccountAccess {
|
||||
* and always null on the Bearer transport.
|
||||
*/
|
||||
active: AccountAccessEntry | null;
|
||||
/** The server-resolved kind of the record currently in view. */
|
||||
recordKind?: AccountRecordKind;
|
||||
/** Is there anywhere to switch to. */
|
||||
canSwitch: boolean;
|
||||
}
|
||||
|
||||
@@ -104,24 +104,32 @@ export async function resolveAccountAccess(auth: {
|
||||
where: { granteeId: actorId, revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
grantor: { select: { id: true, username: true, displayName: true } },
|
||||
grantor: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
managedProfileAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accounts: AccountAccessEntry[] = rows
|
||||
.filter((grant) => isGrantActive(grant, now))
|
||||
.map((grant) => {
|
||||
// One expression, two names. `access` is what v1.36.0 published and
|
||||
// what shipped clients read; `level` is what the three-level contract
|
||||
// calls it. Deriving both from one lookup is what stops them saying
|
||||
// different things about the same row.
|
||||
// `access` is the shipped two-level field, so MANAGE remains write for
|
||||
// clients that have not learned the canonical level. `level` names the
|
||||
// complete three-level contract.
|
||||
const level = LEVEL[grant.access];
|
||||
return {
|
||||
accountId: grant.grantor.id,
|
||||
username: grant.grantor.username,
|
||||
displayName: grant.grantor.displayName,
|
||||
access: level,
|
||||
access: level === "read" ? "read" : "write",
|
||||
level,
|
||||
recordKind:
|
||||
grant.grantor.managedProfileAt === null ? "shared" : "managed",
|
||||
sections: resolveGrantSections(grant.scopeJson),
|
||||
canWrite: grantAllows(grant, "write", now),
|
||||
};
|
||||
@@ -132,5 +140,10 @@ export async function resolveAccountAccess(auth: {
|
||||
? null
|
||||
: (accounts.find((a) => a.accountId === stamped) ?? null);
|
||||
|
||||
return { accounts, active, canSwitch: accounts.length > 0 };
|
||||
return {
|
||||
accounts,
|
||||
active,
|
||||
recordKind: active?.recordKind ?? "self",
|
||||
canSwitch: accounts.length > 0,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user