mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(dashboard): reach shared records from the account switcher, not a card
The "looking after" block sat directly under the greeting and above this account's own day, permanently, for anyone with a single shared record. The dashboard is this account's day; other people's records are a destination, and the account switcher in the top bar and the sidebar already is that door — with the same access level and record kind on each entry. Drop the card, its test, and the five strings only it used, across all six locales. The access-payload consumer guard named that one component as the reader of `level` / `sections` / `recordKind`, so it went red on the deletion, which is what it is for. It now names the surfaces that actually adopt those fields — the presentation resolver and the switcher — and matches the property access rather than one variable name, so a rename of the binding cannot make it match nothing and pass. Verified by removing every reader: it fails. Adds `scripts/i18n-key-tool.mjs` for dropping or rewriting a key across all six bundles in one go.
This commit is contained in:
1 parent
5445bfc1f1
commit
ce9b2814e6
11 files changed
+116
-434
No files matched your search
+1
-6
@@ -9637,13 +9637,8 @@
|
||||
"addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}."
|
||||
},
|
||||
"lookingAfter": {
|
||||
"title": "Ich kümmere mich um",
|
||||
"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"
|
||||
"kindManaged": "Verwaltetes Profil"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profile, die du betreust",
|
||||
|
||||
+1
-6
@@ -9637,13 +9637,8 @@
|
||||
"addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do."
|
||||
},
|
||||
"lookingAfter": {
|
||||
"title": "Looking after",
|
||||
"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"
|
||||
"kindManaged": "Managed profile"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Records you look after",
|
||||
|
||||
+1
-6
@@ -9637,13 +9637,8 @@
|
||||
"addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}."
|
||||
},
|
||||
"lookingAfter": {
|
||||
"title": "Estoy cuidando de",
|
||||
"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}"
|
||||
"kindManaged": "Perfil administrado"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Perfiles de los que te ocupas",
|
||||
|
||||
+1
-6
@@ -9637,13 +9637,8 @@
|
||||
"addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées."
|
||||
},
|
||||
"lookingAfter": {
|
||||
"title": "Je m'occupe de",
|
||||
"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}"
|
||||
"kindManaged": "Profil géré"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profils dont vous vous occupez",
|
||||
|
||||
+1
-6
@@ -9637,13 +9637,8 @@
|
||||
"addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle."
|
||||
},
|
||||
"lookingAfter": {
|
||||
"title": "Mi prendo cura di",
|
||||
"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}"
|
||||
"kindManaged": "Profilo gestito"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profili di cui ti occupi",
|
||||
|
||||
+1
-6
@@ -9637,13 +9637,8 @@
|
||||
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
|
||||
},
|
||||
"lookingAfter": {
|
||||
"title": "Opiekuję się",
|
||||
"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}"
|
||||
"kindManaged": "Zarządzany profil"
|
||||
},
|
||||
"managed": {
|
||||
"title": "Profile, którymi się opiekujesz",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* One-shot helper for a text sweep across the six locale bundles.
|
||||
*
|
||||
* node scripts/i18n-key-tool.mjs drop <dotted.key> [...]
|
||||
* node scripts/i18n-key-tool.mjs set <locale> <dotted.key> <value>
|
||||
* node scripts/i18n-key-tool.mjs show <dotted.key>
|
||||
*
|
||||
* Keys are dotted paths into the bundle; a segment containing a literal dot
|
||||
* is not supported (no such key exists). `drop` removes the key from every
|
||||
* locale and prunes an object left empty behind it.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
|
||||
const file = (loc) => `messages/${loc}.json`;
|
||||
|
||||
function read(loc) {
|
||||
return JSON.parse(readFileSync(file(loc), "utf8"));
|
||||
}
|
||||
function write(loc, data) {
|
||||
writeFileSync(file(loc), `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function drop(bundle, path) {
|
||||
const parts = path.split(".");
|
||||
const last = parts.pop();
|
||||
const chain = [];
|
||||
let node = bundle;
|
||||
for (const part of parts) {
|
||||
if (node == null || typeof node !== "object") return false;
|
||||
chain.push([node, part]);
|
||||
node = node[part];
|
||||
}
|
||||
if (node == null || !(last in node)) return false;
|
||||
delete node[last];
|
||||
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
||||
const [parent, key] = chain[i];
|
||||
if (parent[key] && Object.keys(parent[key]).length === 0)
|
||||
delete parent[key];
|
||||
else break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function set(bundle, path, value) {
|
||||
const parts = path.split(".");
|
||||
const last = parts.pop();
|
||||
let node = bundle;
|
||||
for (const part of parts) {
|
||||
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
|
||||
node = node[part];
|
||||
}
|
||||
node[last] = value;
|
||||
}
|
||||
|
||||
function get(bundle, path) {
|
||||
return path.split(".").reduce((n, p) => (n == null ? n : n[p]), bundle);
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
|
||||
if (cmd === "drop") {
|
||||
for (const loc of LOCALES) {
|
||||
const bundle = read(loc);
|
||||
let n = 0;
|
||||
for (const key of rest) if (drop(bundle, key)) n += 1;
|
||||
write(loc, bundle);
|
||||
console.log(`${loc}: dropped ${n}/${rest.length}`);
|
||||
}
|
||||
} else if (cmd === "set") {
|
||||
const [loc, key, ...valueParts] = rest;
|
||||
const bundle = read(loc);
|
||||
set(bundle, key, valueParts.join(" "));
|
||||
write(loc, bundle);
|
||||
console.log(`${loc}: ${key} set`);
|
||||
} else if (cmd === "show") {
|
||||
for (const loc of LOCALES) {
|
||||
console.log(`${loc}\t${JSON.stringify(get(read(loc), rest[0]))}`);
|
||||
}
|
||||
} else {
|
||||
console.error("usage: drop|set|show");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -80,12 +80,17 @@ 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",
|
||||
);
|
||||
/**
|
||||
* The surfaces that read a per-account access ENTRY. This was one file (the
|
||||
* dashboard's caring-for card) until that card came off the dashboard —
|
||||
* records other people share are reached from the account switcher instead.
|
||||
* A single named file was the fragile part: the guarantee is "every additive
|
||||
* field has a reader", not "this one component still exists".
|
||||
*/
|
||||
const ACCOUNT_ENTRY_CONSUMERS = [
|
||||
join(SRC, "lib", "navigation", "record-presentation.ts"),
|
||||
join(SRC, "components", "layout", "account-switcher-menu.tsx"),
|
||||
];
|
||||
const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts");
|
||||
const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [
|
||||
"level",
|
||||
@@ -231,8 +236,8 @@ describe("account payload consumer guard", () => {
|
||||
});
|
||||
|
||||
it("has non-zero entry and active-record readers for additive access fields", () => {
|
||||
const entrySource = stripComments(
|
||||
readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"),
|
||||
const entrySources = ACCOUNT_ENTRY_CONSUMERS.map((file) =>
|
||||
stripComments(readFileSync(file, "utf8")),
|
||||
);
|
||||
const activeSource = stripComments(
|
||||
readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"),
|
||||
@@ -240,9 +245,17 @@ describe("account payload consumer guard", () => {
|
||||
|
||||
const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce(
|
||||
(count, field) => {
|
||||
expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`));
|
||||
// The reader binds the entry under its own name (`entry`, `account`,
|
||||
// …), so the matcher pins the property access, not the variable. An
|
||||
// empty match set fails rather than passing quietly.
|
||||
const read = new RegExp(`\\w\\.${field}(?![\\w$])`);
|
||||
const readers = entrySources.filter((source) => read.test(source));
|
||||
expect(
|
||||
readers.length,
|
||||
`no client reader for account access field \`${field}\``,
|
||||
).toBeGreaterThan(0);
|
||||
expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
|
||||
return count + 2;
|
||||
return count + readers.length + 1;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { LookingAfterCard } from "@/components/dashboard/looking-after-card";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import React, { Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -931,12 +930,10 @@ export default function DashboardPageClient({
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
|
||||
|
||||
{/* v1.36.x — the caregiver's front door, directly under the greeting
|
||||
and above this account's own day. Somebody who opens the app to
|
||||
check on a parent should not have to go through settings to get
|
||||
there. Renders nothing for an account nobody has shared with, and
|
||||
nothing while a switch is already on. */}
|
||||
<LookingAfterCard />
|
||||
{/* Records other people share with you are reached from the account
|
||||
switcher in the top bar and the sidebar, not from a card here. The
|
||||
dashboard is this account's own day; a permanent block about other
|
||||
people's records sat above it and pushed it down. */}
|
||||
|
||||
{/* S2 — the Today hero, promoted above the tile strip (MARC sign-off
|
||||
decision 1). Renders the S1 daily digest: the day's read, the
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* The caregiver's front door, rendered.
|
||||
*
|
||||
* SSR-only, like the rest of the component suite: what is proven is the
|
||||
* paint — who the card lists and when it exists at all. The tap that switches
|
||||
* records is the switcher's existing mutation and is driven end to end in
|
||||
* `e2e/account-sharing.spec.ts`.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - drop the `accounts.length === 0` bail → "an account nobody shares with
|
||||
* sees nothing" goes red.
|
||||
* - drop the `active != null` bail → "no doorway into the room you are in"
|
||||
* goes red.
|
||||
* - render the level from `entry.access` instead of `entry.canWrite` → "the
|
||||
* level is the server's resolved boolean" goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
const GRANDMOTHER = {
|
||||
accountId: "acct-gran",
|
||||
username: "gran",
|
||||
displayName: "Margarethe",
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
const FATHER = {
|
||||
accountId: "acct-dad",
|
||||
username: "dad",
|
||||
displayName: null,
|
||||
access: "write" as const,
|
||||
level: "write" as const,
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
const MANAGED_PROFILE = {
|
||||
accountId: "managed-profile",
|
||||
username: "managed-profile",
|
||||
displayName: "Managed profile",
|
||||
access: "write" as const,
|
||||
level: "manage" as const,
|
||||
sections: null,
|
||||
recordKind: "managed" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
|
||||
const mockAccount = () => ({
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockAccount(),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
// The card reads the mount-pinned accessor: it paints inside the dashboard's
|
||||
// streamed boundary, where reading the grants on the hydration render would
|
||||
// disagree with the server and cost the whole streamed tree. These cases are
|
||||
// about the MOUNTED render, so the accessor answers the account.
|
||||
useAccountOnceMounted: () => mockAccount(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
import { LookingAfterCard } from "../looking-after-card";
|
||||
|
||||
function render(access: AccountAccess): string {
|
||||
mockAccessRef.value = access;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LookingAfterCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LookingAfterCard>", () => {
|
||||
it("an account nobody shares with sees nothing", () => {
|
||||
// The v1.36.0 posture: the feature is invisible until it applies to you.
|
||||
// Not an empty state, not a prompt to ask somebody for access.
|
||||
expect(render({ accounts: [], active: null, canSwitch: false })).toBe("");
|
||||
});
|
||||
|
||||
it("lists every record shared with this account", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER, FATHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="looking-after-card"');
|
||||
expect(html).toContain('data-account-id="acct-gran"');
|
||||
expect(html).toContain('data-account-id="acct-dad"');
|
||||
// The display name where there is one, the username where there is not.
|
||||
expect(html).toContain("Margarethe");
|
||||
expect(html).toContain("dad");
|
||||
});
|
||||
|
||||
it("the level is the server's resolved boolean", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER, FATHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain("Can view and add");
|
||||
expect(html).toContain("Can view");
|
||||
});
|
||||
|
||||
it("a grant labelled write but resolved read reads as read", () => {
|
||||
// `access` describes the row; `canWrite` is the decision the server made
|
||||
// about it this second. A grant that lapsed, or a level this build does
|
||||
// not honour, must not promise an add the next request would refuse.
|
||||
const html = render({
|
||||
accounts: [
|
||||
{ ...FATHER, access: "write", level: "write", canWrite: false },
|
||||
],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain("Can view");
|
||||
expect(html).not.toContain("Can view and add");
|
||||
});
|
||||
|
||||
it("keeps MANAGE and managed profile labels separate", () => {
|
||||
const html = render({
|
||||
accounts: [MANAGED_PROFILE],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
|
||||
expect(html).toContain('data-access-level="manage"');
|
||||
expect(html).toContain('data-record-kind="managed"');
|
||||
expect(html).toContain("Can read the record, add to it, and change");
|
||||
expect(html).toContain("Managed profile");
|
||||
});
|
||||
|
||||
it("names the record button with its action, access, and kind", () => {
|
||||
const html = render({
|
||||
accounts: [MANAGED_PROFILE],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
|
||||
expect(html).toMatch(
|
||||
/aria-label="Open Managed profile's record; Can read the record, add to it, and change or remove what is in it; Managed profile;/,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no health data, only a doorway", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
// A preview would put somebody else's readings on this dashboard, outside
|
||||
// the banner frame that says which record you are looking at. The card
|
||||
// carries names and levels and nothing else.
|
||||
expect(html).not.toMatch(/\d+(\.\d+)?\s*(kg|mmHg|bpm)/);
|
||||
});
|
||||
|
||||
it("no doorway into the room you are already in", () => {
|
||||
expect(
|
||||
render({
|
||||
accounts: [GRANDMOTHER],
|
||||
active: GRANDMOTHER,
|
||||
canSwitch: true,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { HeartHandshake, Loader2 } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAccountOnceMounted } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
resolveRecordPresentation,
|
||||
type RecordPresentation,
|
||||
} from "@/lib/navigation/record-presentation";
|
||||
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.36.x — the caregiver's front door.
|
||||
*
|
||||
* ## Why this is on the dashboard and not in settings
|
||||
*
|
||||
* Sharing shipped as a settings panel and a menu three levels deep: user menu
|
||||
* → switcher → the record. That is the right place to MANAGE a grant, and the
|
||||
* wrong place to use one. Somebody who opens this app every morning to check
|
||||
* on a parent does not go to settings to do it, and UI-STANDARDS §10 draws
|
||||
* exactly that line: settings are account utilities, not feature
|
||||
* destinations. Looking after somebody is a feature destination.
|
||||
*
|
||||
* ## What it is not
|
||||
*
|
||||
* It is a doorway, not a preview. No numbers, no last reading, no status.
|
||||
* Putting a slice of somebody else's health record on a delegate's own
|
||||
* dashboard would place it outside the one frame the whole design leans on —
|
||||
* the banner that says which record you are inside. Everything about the
|
||||
* other person is read after the switch, under that banner, or not at all.
|
||||
*
|
||||
* ## Where the content comes from
|
||||
*
|
||||
* `accountAccess.accounts`, the same resolved list the switcher renders, and
|
||||
* the switch is the same mutation the switcher fires. No second mechanism, no
|
||||
* second source of truth about who may open what. An account nobody has
|
||||
* shared with sees nothing at all — no empty state, no invitation to ask
|
||||
* somebody for access (the v1.36.0 posture: the feature is invisible until it
|
||||
* applies to you).
|
||||
*
|
||||
* Design: `Card` + `TileHeader` per UI-STANDARDS §1 and §5 (icon and title in
|
||||
* the foreground colour, title `text-base`), the person's name as content in
|
||||
* `text-foreground` and the grant level as meta in `text-muted-foreground`
|
||||
* per §3, and the §11 row shape — label block `min-w-0 flex-1`, the whole row
|
||||
* a single tap target at the `min-h-11` mobile floor.
|
||||
*/
|
||||
export function LookingAfterCard() {
|
||||
const { t } = useTranslations();
|
||||
// Withheld until mounted: this card sits inside the dashboard's streamed
|
||||
// boundary and renders nothing without grants. The shell has usually answered
|
||||
// `/api/auth/me` before the boundary hydrates, so reading the grants on the
|
||||
// hydration render paints a card where the server painted none — a mismatch
|
||||
// that discards the streamed dashboard. See `useAccountOnceMounted`.
|
||||
const user = useAccountOnceMounted();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const access = user?.accountAccess;
|
||||
const accounts = access?.accounts ?? [];
|
||||
|
||||
// Inside somebody else's record the card would be a doorway to the room you
|
||||
// are already in; the banner owns that context and carries the way out.
|
||||
if (access?.active != null) return null;
|
||||
if (accounts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="looking-after-card">
|
||||
<CardContent className="space-y-3">
|
||||
<TileHeader
|
||||
icon={HeartHandshake}
|
||||
title={t("recordSharing.lookingAfter.title")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("recordSharing.lookingAfter.description")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{accounts.map((entry) => {
|
||||
const presentation = resolveRecordPresentation(entry);
|
||||
const level = levelLabel(entry, presentation, t);
|
||||
const kind = recordKindLabel(entry, t);
|
||||
const scope = scopeLabel(entry, t);
|
||||
|
||||
return (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
entry.accountId,
|
||||
sharedRecordLandingHref(entry.sections),
|
||||
)
|
||||
}
|
||||
aria-label={[
|
||||
t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
}),
|
||||
level,
|
||||
kind,
|
||||
scope,
|
||||
].join("; ")}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{level}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{kind}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{scope}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant level, in the words the person reading it would use.
|
||||
*
|
||||
* Bound from the server's resolved `canWrite`, never from the `access` label
|
||||
* beside it: the label describes the row, the boolean is the decision, and a
|
||||
* lapsed WRITE grant must read as view-only here exactly as it behaves.
|
||||
*
|
||||
* No "last opened" line: `lastUsedAt` is not on this payload, and a relative
|
||||
* time invented from something else would be a placeholder wearing a fact's
|
||||
* clothes.
|
||||
*/
|
||||
function levelLabel(
|
||||
entry: AccountAccessEntry,
|
||||
presentation: RecordPresentation,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (entry.level === "manage") {
|
||||
return t("recordSharing.row.canManage");
|
||||
}
|
||||
return presentation.access === "view-and-add"
|
||||
? 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(", ");
|
||||
}
|
||||
Reference in new issue
Block a user