diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts index 529d127c4..adedc711f 100644 --- a/e2e/v138-managed-record-capabilities.spec.ts +++ b/e2e/v138-managed-record-capabilities.spec.ts @@ -16,6 +16,12 @@ * control is absent and the page says why, in a sentence, where the * control would have been. * + * A third journey reaches the profile's Settings from the navigation (#939). + * The Settings shell has always listed a managed profile's own configuration, + * and the section gate has always admitted it, but no navigation led there: + * the utility entries disappeared for every shared record. It walks the + * desktop sidebar and the mobile user menu to the Modules card. + * * Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are * the same ones, for the same reasons written there. */ @@ -137,6 +143,73 @@ test.describe.serial("a guardian's controls inside a managed profile", () => { await leaveRecord(page); }); + test("reaches the profile's Modules settings from the navigation on both widths", async ({ + page, + }) => { + await openRecord(page, managed.username); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + const accountId = await banner.getAttribute("data-account-id"); + expect(accountId).toBeTruthy(); + + // The banner renders from the same `/api/auth/me` answer the navigation + // reads, and the shell paints nothing before it resolves. Waiting on it + // above is what makes every presence and absence below a statement about + // the loaded shell rather than about the hydration gate. + const sidebar = page.locator('aside[aria-label="Sidebar"]'); + const settings = sidebar.locator('[data-slot="nav-settings-link"]'); + await expect(settings).toHaveCount(1); + await expect(settings).toHaveAttribute("href", "/settings/account"); + await expect(sidebar.locator('a[href="/notifications"]')).toHaveCount(0); + + await settings.click(); + await expect(page).toHaveURL(/\/settings\/account$/); + await expect( + page.locator('[data-record-settings-family="profile"]'), + ).toHaveAttribute("data-record-id", accountId as string, { + timeout: 30_000, + }); + + await page + .locator('a[href="/settings/modules"]') + .filter({ visible: true }) + .first() + .click(); + await expect(page).toHaveURL(/\/settings\/modules$/); + const modules = page.locator( + '[data-record-settings-family="modules"][aria-busy="false"]', + ); + await expect(modules).toBeVisible({ timeout: 30_000 }); + await expect(modules).toHaveAttribute( + "data-record-id", + accountId as string, + ); + + // The phone layout: the utilities live in the top-bar user menu there. + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await expect(banner).toBeVisible({ timeout: 30_000 }); + const topBar = page.locator('[data-slot="top-bar"]'); + await expect(topBar).toBeVisible(); + await topBar.getByRole("button", { name: "User menu" }).click(); + const menu = page.getByRole("menu"); + const menuSettings = menu.locator('[data-slot="nav-settings-link"]'); + await expect(menuSettings).toHaveAttribute("href", "/settings/account"); + // The menu is open and populated, so this absence is the menu's answer. + await expect(menu.locator('a[href="/notifications"]')).toHaveCount(0); + await menuSettings.click(); + await expect(page).toHaveURL(/\/settings\/account$/); + await expect( + page.locator('[data-record-settings-family="profile"]'), + ).toHaveAttribute("data-record-id", accountId as string, { + timeout: 30_000, + }); + + await page.setViewportSize({ width: 1280, height: 720 }); + await leaveRecord(page); + }); + test("withholds the vault's upload control and says why", async ({ page, }) => { diff --git a/messages/de.json b/messages/de.json index 3a48fb6f1..3c70d965c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10389,7 +10389,7 @@ "accessManageLabel": "Darf verwalten", "accessManageCapability": "Sie kann Einträge anlegen, ändern und löschen, auch solche, die du selbst erfasst hast.", "accessManageDetail": "Sie kann außerdem die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.", - "manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.", + "manageStepUpNote": "Das geht nur im Browser, und falls du einen zweiten Faktor eingerichtet hast, wird er abgefragt.", "scopeLegend": "Was die Person öffnen darf", "scopeAllLabel": "Gesamte Akte", "scopeAllCapability": "Jeder Teil davon, so wie das Teilen bisher funktioniert hat.", @@ -10413,7 +10413,7 @@ "errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.", "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.", "errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.", - "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben, weil dabei dein zweiter Faktor abgefragt wird.", + "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben.", "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", "errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier — prüfe deine Verbindung und versuche es erneut." }, diff --git a/messages/en.json b/messages/en.json index b2a37305f..e558f8892 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10389,7 +10389,7 @@ "accessManageLabel": "Can manage", "accessManageCapability": "They can add, change and remove entries, including ones you entered yourself.", "accessManageDetail": "They can also read the insights generated from your record. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.", - "manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.", + "manageStepUpNote": "Offering this only works in a browser, and asks for your second factor if you have one.", "scopeLegend": "What they can open", "scopeAllLabel": "Entire record", "scopeAllCapability": "Every part of it, the way sharing has always worked.", @@ -10413,7 +10413,7 @@ "errorRateLimited": "Too many invitations. Please try again later.", "errorFailed": "The invitation could not be sent. Please try again.", "errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.", - "errorManageBrowserOnly": "Manage access can only be offered in a browser, because it asks for your second factor.", + "errorManageBrowserOnly": "Manage access can only be offered in a browser.", "errorStepUp": "Confirm your second factor, then try again.", "errorOffline": "That did not reach the server. Your invitation is still here — check your connection and try again." }, diff --git a/messages/es.json b/messages/es.json index e3f4ca56c..a42c28ad0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10389,7 +10389,7 @@ "accessManageLabel": "Puede gestionar", "accessManageCapability": "Puede añadir, cambiar y eliminar entradas, incluidas las que has escrito tú.", "accessManageDetail": "También puede leer los análisis generados a partir de tu ficha. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.", - "manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.", + "manageStepUpNote": "Solo se puede hacer en un navegador, y pide tu segundo factor si tienes uno.", "scopeLegend": "Qué puede abrir", "scopeAllLabel": "Todo el historial", "scopeAllCapability": "Todas sus partes, tal y como ha funcionado siempre el uso compartido.", @@ -10413,7 +10413,7 @@ "errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.", "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.", "errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.", - "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador, porque pide tu segundo factor.", + "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador.", "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", "errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo." }, diff --git a/messages/fr.json b/messages/fr.json index 48fc47a50..cc4fed784 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10389,7 +10389,7 @@ "accessManageLabel": "Peut gérer", "accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées, y compris celles que vous avez saisies vous-même.", "accessManageDetail": "Elle peut aussi lire les analyses tirées de votre dossier. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.", - "manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.", + "manageStepUpNote": "Cette offre n'est possible que dans un navigateur, et demande votre deuxième facteur si vous en avez un.", "scopeLegend": "Ce que cette personne peut ouvrir", "scopeAllLabel": "Dossier complet", "scopeAllCapability": "Toutes ses parties, comme le partage a toujours fonctionné.", @@ -10413,7 +10413,7 @@ "errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.", "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.", "errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.", - "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur, car il demande votre deuxième facteur.", + "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur.", "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", "errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là — vérifiez votre connexion et réessayez." }, diff --git a/messages/it.json b/messages/it.json index 0e97ed7be..3dc9470e5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10389,7 +10389,7 @@ "accessManageLabel": "Può gestire", "accessManageCapability": "Può aggiungere, modificare ed eliminare voci, comprese quelle inserite da te.", "accessManageDetail": "Può anche leggere le analisi generate dalla tua cartella. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.", - "manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.", + "manageStepUpNote": "Si può fare solo da browser, e viene richiesto il tuo secondo fattore se ne hai uno.", "scopeLegend": "Che cosa può aprire", "scopeAllLabel": "Intera cartella", "scopeAllCapability": "Ogni sua parte, come la condivisione ha sempre funzionato.", @@ -10413,7 +10413,7 @@ "errorRateLimited": "Troppi inviti. Riprova più tardi.", "errorFailed": "Non è stato possibile inviare l'invito. Riprova.", "errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.", - "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser, perché richiede il tuo secondo fattore.", + "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser.", "errorStepUp": "Conferma il tuo secondo fattore e riprova.", "errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova." }, diff --git a/messages/ko.json b/messages/ko.json index d09db31aa..a8a6a3f8f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10389,7 +10389,7 @@ "accessManageLabel": "관리 가능", "accessManageCapability": "내가 직접 입력한 것까지 포함해 기록을 추가·수정·삭제할 수 있어요.", "accessManageDetail": "내 기록에서 만들어진 인사이트도 볼 수 있어요. 내 로그인, 보안 설정, 연동, 다른 사람의 권한은 바꿀 수 없어요. 언제든 이 권한을 끝낼 수 있어요.", - "manageStepUpNote": "이 권한을 주려면 2단계 인증이 필요해서 브라우저에서만 할 수 있어요.", + "manageStepUpNote": "브라우저에서만 할 수 있고, 2단계 인증을 설정했다면 인증을 요청해요.", "scopeLegend": "상대가 열 수 있는 범위", "scopeAllLabel": "기록 전체", "scopeAllCapability": "기록의 모든 부분. 지금까지의 공유 방식 그대로예요.", @@ -10413,7 +10413,7 @@ "errorRateLimited": "초대가 너무 많아요. 잠시 후 다시 시도해 주세요.", "errorFailed": "초대를 보내지 못했어요. 다시 시도해 주세요.", "errorScope": "그 섹션들은 그렇게 공유할 수 없어요. 섹션을 하나 이상 고르거나 기록 전체를 공유하세요.", - "errorManageBrowserOnly": "관리 권한은 2단계 인증이 필요해서 브라우저에서만 줄 수 있어요.", + "errorManageBrowserOnly": "관리 권한은 브라우저에서만 줄 수 있어요.", "errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.", "errorOffline": "서버에 닿지 못했어요. 초대 내용은 그대로 있으니 연결을 확인하고 다시 시도해 주세요." }, diff --git a/messages/pl.json b/messages/pl.json index 3c84cbc85..a87b93e0b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10389,7 +10389,7 @@ "accessManageLabel": "Może zarządzać", "accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy, także te, które wprowadziłeś samodzielnie.", "accessManageDetail": "Może też czytać analizy wygenerowane na podstawie Twojej kartoteki. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.", - "manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.", + "manageStepUpNote": "Można to zrobić tylko w przeglądarce, a jeśli masz drugi składnik logowania, poprosimy o niego.", "scopeLegend": "Co ta osoba może otworzyć", "scopeAllLabel": "Cała kartoteka", "scopeAllCapability": "Każda jej część, tak jak udostępnianie działało do tej pory.", @@ -10413,7 +10413,7 @@ "errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.", "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.", "errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.", - "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce, ponieważ wymaga drugiego składnika logowania.", + "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce.", "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", "errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest — sprawdź połączenie i spróbuj ponownie." }, diff --git a/src/components/layout/__tests__/nav-model-shared-record.test.ts b/src/components/layout/__tests__/nav-model-shared-record.test.ts index 9da920c9e..5b2a43239 100644 --- a/src/components/layout/__tests__/nav-model-shared-record.test.ts +++ b/src/components/layout/__tests__/nav-model-shared-record.test.ts @@ -14,9 +14,24 @@ * Mutation check, run: flip the default in `isNavDestinationVisible` so an * unflagged destination survives a switch → "hides everything that has not * been classified" goes red naming Insights and the Coach. + * + * Mutation checks for the utility tail, run against `visibleUtilityDestinations`: + * - restore the old empty tail for every shared record → both "offers + * Settings" cases and the parity case go red (3); + * - point the switched entry at the fixed `/settings/account` → the adult + * MANAGE case and the parity case go red (2); + * - keep Notifications in the switched tail beside the remapped Settings + * entry → "never offers Notifications under a switch" goes red, with the + * two "offers Settings" cases and the parity case (4). */ import { describe, expect, it } from "vitest"; +import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell"; +import { + isSettingsDestinationListedForRecord, + type SettingsRecordContext, +} from "@/lib/record-settings/classification"; + import { BOTTOM_NAV_PRIMARY_SLOT_HREFS, NAV_DESTINATIONS, @@ -116,11 +131,93 @@ describe("a module the RECORD does not track", () => { }); describe("the utility tail under a switch", () => { - it("disappears entirely", () => { - // Settings holds credentials, integrations, notification channels and - // grant management; Notifications is the delegate's own device business. - expect(visibleUtilityDestinations({ sharedRecord: true })).toEqual([]); - expect(visibleUtilityDestinations().length).toBeGreaterThan(0); + const MANAGED_AT_MANAGE: SettingsRecordContext = { + recordKind: "managed", + level: "manage", + }; + const SHARED_AT_MANAGE: SettingsRecordContext = { + recordKind: "shared", + level: "manage", + }; + + const hrefs = (record: SettingsRecordContext | null) => + visibleUtilityDestinations({ record }).map((d) => d.href); + + it("keeps both utilities in one's own record", () => { + expect(hrefs(null)).toEqual(["/settings/account", "/notifications"]); + expect(visibleUtilityDestinations().map((d) => d.href)).toEqual( + hrefs(null), + ); + }); + + /** + * #939 — a self-hoster looking after a managed profile was told to open + * Settings → Modules inside it and could not find Settings anywhere: the + * tail used to be empty for every shared record, while the Settings shell + * lists the profile's own configuration there. The entry is offered again, + * and it opens on a page the shell admits. + */ + it("offers Settings inside a managed profile, because the shell has pages for it there", () => { + const tail = visibleUtilityDestinations({ record: MANAGED_AT_MANAGE }); + expect(tail.map((d) => d.tKey)).toEqual(["nav.settings"]); + // The profile card: `account` is classified managed-guardian and is the + // first section the shell lists for a managed record. + expect(tail[0].href).toBe("/settings/account"); + }); + + it("offers Settings to an adult MANAGE share, landing on the only page it opens", () => { + // `/settings/account` is refused in an adult share; the one destination + // listed there is the record content, so the entry must not point at the + // account page the bar uses in one's own record. + expect(hrefs(SHARED_AT_MANAGE)).toEqual(["/settings/anamnesis"]); + }); + + it("offers nothing to a READ or WRITE share, where the shell lists nothing", () => { + for (const recordKind of ["shared", "managed"] as const) { + for (const level of ["read", "write"] as const) { + expect(hrefs({ recordKind, level })).toEqual([]); + } + } + }); + + it("offers nothing to a refused or pending switch, which carries no level", () => { + // `resolveRecordCapabilities` answers `recordKind: "shared", level: null` + // for both; a Settings entry there would lead into a context nobody has + // proven. + expect(hrefs({ recordKind: "shared", level: null })).toEqual([]); + }); + + it("never offers Notifications under a switch", () => { + // Notifications is the delegate's own device business; its routes refuse + // under a switch at every level and for every record kind. + for (const recordKind of ["shared", "managed"] as const) { + for (const level of ["read", "write", "manage", null] as const) { + expect(hrefs({ recordKind, level })).not.toContain("/notifications"); + } + } + }); + + it("lands on the first section the Settings shell lists, for every record kind", () => { + // The parity net. The navigation walks the slug registry, the shell walks + // its own ordered section list; both filter with the same predicate, and + // this holds the first answers equal so a reorder on either side cannot + // land somebody on a page the shell does not open with. + const contexts: SettingsRecordContext[] = [ + MANAGED_AT_MANAGE, + SHARED_AT_MANAGE, + ]; + for (const record of contexts) { + const firstListed = SETTINGS_SECTIONS.find((section) => + isSettingsDestinationListedForRecord(section.slug, record), + ); + // Non-zero: a predicate that listed nothing would make both sides + // agree on "no entry" and prove nothing about the landing. + expect(firstListed, record.recordKind).toBeDefined(); + // A module-gated landing could vanish from the shell when its module is + // off, leaving the entry pointing at a section the list no longer shows. + expect(firstListed?.moduleGate, record.recordKind).toBeUndefined(); + expect(hrefs(record)).toEqual([`/settings/${firstListed?.slug}`]); + } }); }); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index f31a5088a..ea1c5d49c 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; // `` reads the active route from `usePathname()`. Stub @@ -243,3 +245,95 @@ describe(" admin entry mirrors Settings (no sub-item expansion)", () expect(html).not.toMatch(/aria-label="Admin sections"/); }); }); + +describe(" inside somebody else's record (#939)", () => { + /** + * A self-hoster looking after a managed profile could not find Settings + * anywhere after switching into it, although the Settings shell lists the + * profile's own configuration there. The footer entry is back wherever the + * shell has pages for the record, and points at the first of them; the + * identity block stays a plain block, because it names the person at the + * keyboard and must not open somebody else's settings under that name. + * + * Mutation checks, run: + * - `FooterIdentity` always rendering the link → all three switched cases + * go red: the managed case on the account-settings label, the adult + * MANAGE and READ cases on the identity link's `/settings/account` href; + * - the empty tail restored for shared records in + * `visibleUtilityDestinations` → both MANAGE cases go red on the missing + * Settings link, while the READ case stays green. + */ + const accountSettingsLabel = ( + JSON.parse( + readFileSync(join(process.cwd(), "messages/en.json"), "utf8"), + ) as { nav: { accountSettings: string } } + ).nav.accountSettings; + + function renderInRecord( + active: { + recordKind: "managed" | "shared"; + level: "read" | "write" | "manage"; + } | null, + ) { + const user = mockUserRef.value as typeof mockUserRef.value & { + accountAccess?: unknown; + }; + user.accountAccess = active + ? { + accounts: [], + canSwitch: true, + active: { + accountId: "record-1", + username: "record", + displayName: null, + fullName: null, + recordKind: active.recordKind, + level: active.level, + accessLevel: active.level === "read" ? "read" : "write", + canWrite: active.level !== "read", + sections: null, + writableDomains: [], + manageableDomains: + active.level === "manage" ? ["measurements"] : [], + }, + } + : undefined; + try { + return render(); + } finally { + delete user.accountAccess; + } + } + + const settingsLinks = (html: string) => + [...html.matchAll(/]*data-slot="nav-settings-link"[^>]*>/g)].map( + (match) => /href="([^"]+)"/.exec(match[0])?.[1], + ); + + it("keeps the account settings door in one's own record", () => { + const html = renderInRecord(null); + expect(settingsLinks(html)).toEqual(["/settings/account"]); + expect(html).toContain(`aria-label="${accountSettingsLabel}"`); + }); + + it("offers Settings inside a managed profile and keeps the identity block a plain block", () => { + const html = renderInRecord({ recordKind: "managed", level: "manage" }); + expect(settingsLinks(html)).toEqual(["/settings/account"]); + expect(html).not.toContain(`aria-label="${accountSettingsLabel}"`); + expect(html).not.toContain('href="/notifications"'); + }); + + it("lands an adult MANAGE share on the one Settings page it opens", () => { + const html = renderInRecord({ recordKind: "shared", level: "manage" }); + expect(settingsLinks(html)).toEqual(["/settings/anamnesis"]); + expect(html).not.toContain('href="/settings/account"'); + }); + + it("offers no Settings entry to a READ share", () => { + const html = renderInRecord({ recordKind: "shared", level: "read" }); + // Non-zero proof that the sidebar rendered at all. + expect(html).toContain('href="/measurements"'); + expect(settingsLinks(html)).toEqual([]); + expect(html).not.toContain('href="/settings/'); + }); +}); diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 47f9c9fbd..9b1e2934b 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -20,6 +20,12 @@ import { } from "lucide-react"; import type { ModuleKey } from "@/lib/modules/registry"; +// The leaf module, not the `@/lib/record-settings` barrel, for the reason +// `auth-shell.tsx` gives: the barrel drags server-side schemas into the chrome. +import { + recordSettingsLandingDestination, + type SettingsRecordContext, +} from "@/lib/record-settings/classification"; import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record"; import type { ShareDomain } from "@/lib/sharing/scope"; @@ -277,19 +283,18 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ /** * v1.17.1 (F-1 residue) — the shared UTILITY tail. * - * Settings and Notifications are reachable on both bars but are not feature - * destinations: on desktop they live in the sidebar footer + avatar menu, on - * mobile at the tail of the "More" hub. They used to be a second hand-curated - * list on each bar — the exact drift the one-model contract above set out to - * kill, just pushed down a level. This list is the single source both bars - * consume for the tail, so the two surfaces can no longer disagree on which - * utility links exist or in which order. + * Settings and Notifications are account utilities, not feature destinations: + * on desktop they live in the sidebar footer + avatar menu, on mobile in the + * top-bar user menu (UI-STANDARDS §10; the More hub carries features only). + * They used to be a second hand-curated list on each bar — the exact drift the + * one-model contract above set out to kill, just pushed down a level. This + * list is the single source both bars consume, so the two surfaces can no + * longer disagree on which utility links exist or in which order. * - * Order is the footer/hub order: Settings → Notifications. + * Order is the footer/menu order: Settings → Notifications. * - * Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only - * surface (the mobile bar never exposes it), so it is not a shared tail - * destination and stays local to the sidebar. + * Admin is intentionally NOT here: it is a role-gated surface that never + * appears under a switch, so it stays local to each bar. */ export interface NavUtilityDestination { href: string; @@ -303,21 +308,41 @@ export const NAV_UTILITY_DESTINATIONS: ReadonlyArray = [ { href: "/notifications", tKey: "nav.notifications", icon: Bell }, ]; +/** Is this utility entry the Settings door, wherever it lands. */ +export function isSettingsUtilityDestination( + d: Pick, +): boolean { + return d.href.startsWith("/settings/"); +} + /** - * The utility tail visible to this account. Both bars consume this for their - * tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two - * surfaces share one definition of the utility links. + * The utility tail visible to this session. Both bars consume it, so the + * sidebar footer and the mobile user menu share one definition. + * + * `record` is the shared record the browser is acting on, as its + * server-resolved kind and grant level; absent or null is one's own record. + * + * Under a switch, Notifications is never offered: it is the delegate's own + * device business, and its routes refuse. Settings is offered exactly when + * the Settings shell would list at least one section for that record, and + * it lands on the first section the shell lists — a managed profile at MANAGE + * opens on its profile card, an adult share at MANAGE on the anamnesis page. + * Both answers come from `recordSettingsLandingDestination`, which reads the + * predicate the shell filters its own list with, so the entry cannot lead to + * a page the shell refuses and cannot be missing where the shell has pages. + * Every other shared context — READ, WRITE, a refused or pending switch — + * gets no utility at all. */ export function visibleUtilityDestinations( - opts: { sharedRecord?: boolean } = {}, + opts: { record?: SettingsRecordContext | null } = {}, ): NavUtilityDestination[] { - // v1.36.0 — the utility tail is account configuration by definition: - // Settings holds credentials, integrations, notification channels and grant - // management, and Notifications is the delegate's own device business. Every - // route behind both refuses under a switch; the tail simply stops being - // offered so nobody walks into the refusal. - if (opts.sharedRecord) return []; - return [...NAV_UTILITY_DESTINATIONS]; + const record = opts.record ?? null; + if (record === null) return [...NAV_UTILITY_DESTINATIONS]; + const landing = recordSettingsLandingDestination(record); + if (landing === null) return []; + return NAV_UTILITY_DESTINATIONS.filter(isSettingsUtilityDestination).map( + (d) => ({ ...d, href: `/settings/${landing}` }), + ); } /** diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 3d8588db9..90edb5afd 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -20,6 +20,7 @@ import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-m import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, + isSettingsUtilityDestination, visibleNavDestinations, visibleUtilityDestinations, } from "@/components/layout/nav-model"; @@ -64,10 +65,12 @@ function getInitials(name: string): string { * The sidebar footer's name-and-avatar block. * * A link into the account settings normally, and a plain block while this - * browser is acting on somebody else's record — the settings routes refuse - * under a switch, so the link would lead only to an explanation of why it did - * not work. It keeps naming the person at the keyboard either way, which is - * what a switched session needs it to do. + * browser is acting on somebody else's record. The block names the person at + * the keyboard, and `/settings/account` inside a record is either refused (an + * adult share) or the RECORD's profile card (a managed profile) — a door on + * one's own name into somebody else's settings would mislabel both. A record + * that has Settings pages reaches them through the footer Settings entry, + * which is labelled as Settings and lands where the shell opens. */ function FooterIdentity({ sharedRecord, @@ -307,7 +310,12 @@ export function SidebarNav() { // v1.36.0 — while this browser is acting on somebody else's record, the nav // shows only what sharing covers. The server refuses the rest regardless; // dropping the entries spares a delegate a click that ends in a 403. - const { inSharedRecord: sharedRecord, sections } = useRecordCapabilities(); + const { + inSharedRecord: sharedRecord, + sections, + recordKind, + level, + } = useRecordCapabilities(); const visibleNavItems = useMemo( () => visibleNavDestinations(user?.modules, mounted, sharedRecord, sections), @@ -347,22 +355,26 @@ export function SidebarNav() { } // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from - // the SAME shared list the mobile More-hub tail consumes, so the two - // surfaces can no longer drift into two hand-curated utility lists. - // Notifications is surfaced in the avatar menu (not the footer), so the - // footer takes every utility entry except `/notifications`; Admin is the - // role-gated, sidebar-only surface and is inserted separately below. + // the SAME shared list the mobile user menu consumes, so the two surfaces + // can no longer drift into two hand-curated utility lists. Notifications is + // surfaced in the avatar menu (not the footer), so the footer takes every + // utility entry except `/notifications`; Admin is the role-gated, + // sidebar-only surface and is inserted separately below. + // + // Under a switch the list answers for the record on screen: a Settings + // entry exactly when the Settings shell lists a section for it, pointing at + // the first one (see `visibleUtilityDestinations`). const footerUtilityItems = useMemo( () => - visibleUtilityDestinations({ sharedRecord }).filter( - (d) => d.href !== "/notifications", - ), - [sharedRecord], + visibleUtilityDestinations({ + record: sharedRecord ? { recordKind, level } : null, + }).filter((d) => d.href !== "/notifications"), + [sharedRecord, recordKind, level], ); function isUtilityActive(href: string) { // Settings matches the whole `/settings/*` shell; the rest match exact. - return href === "/settings/account" + return isSettingsUtilityDestination({ href }) ? pathname.startsWith("/settings") : pathname === href; } @@ -374,8 +386,10 @@ export function SidebarNav() { }) { const Icon = item.icon; const isActive = isUtilityActive(item.href); - const tourId = - item.href === "/settings/account" ? "nav-settings" : undefined; + const isSettings = isSettingsUtilityDestination(item); + const tourId = isSettingsUtilityDestination(item) + ? "nav-settings" + : undefined; if (collapsed) { return ( @@ -384,6 +398,7 @@ export function SidebarNav() { href={item.href} aria-current={isActive ? "page" : undefined} data-tour-id={tourId} + data-slot={isSettings ? "nav-settings-link" : undefined} className={cn( "flex items-center justify-center rounded-lg p-2.5 transition-colors", isActive @@ -406,6 +421,7 @@ export function SidebarNav() { href={item.href} aria-current={isActive ? "page" : undefined} data-tour-id={tourId} + data-slot={isSettings ? "nav-settings-link" : undefined} className={cn( "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", isActive @@ -607,11 +623,11 @@ export function SidebarNav() { role-gated Admin entry inserted before Settings. */}
{footerUtilityItems - .filter((item) => item.href !== "/settings/account") + .filter((item) => !isSettingsUtilityDestination(item)) .map((item) => renderUtilityLink(item))} {renderAdminLink()} {footerUtilityItems - .filter((item) => item.href === "/settings/account") + .filter((item) => isSettingsUtilityDestination(item)) .map((item) => renderUtilityLink(item))}
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 2a64baa23..fccdb8be1 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -1,17 +1,19 @@ "use client"; import { - Bell, ChevronDown, LogIn, LogOut, Monitor, Moon, - Settings, Shield, Sun, } from "lucide-react"; import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; +import { + isSettingsUtilityDestination, + visibleUtilityDestinations, +} from "@/components/layout/nav-model"; import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; @@ -19,6 +21,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import Link from "next/link"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; import { @@ -42,8 +45,17 @@ function getInitials(name: string): string { export function TopBar() { const { user, isLoading } = useAuth(); - // v1.36.0 — acting on somebody else's record; see the menu below. - const sharedRecord = user?.accountAccess?.active != null; + // v1.36.0 — acting on somebody else's record; see the menu below. Read from + // the capability hook, as the sidebar does, so a refused or pending switch + // counts as switched here too and both bars offer the same utilities. + const { + inSharedRecord: sharedRecord, + recordKind, + level, + } = useRecordCapabilities(); + const utilityItems = visibleUtilityDestinations({ + record: sharedRecord ? { recordKind, level } : null, + }); const logout = useLogout(); const { theme, setTheme } = useTheme(); const { t } = useTranslations(); @@ -115,34 +127,38 @@ export function TopBar() { - {/* v1.36.0 — the account utilities drop out while this browser is - acting on somebody else's record. Every route behind them - refuses under a switch, and a control that leads only to an - explanation of why it does not work is worse than no control. - The switcher below is how the person gets back. */} - {!sharedRecord && ( - <> - - - - {t("nav.settings")} + {/* The account utilities come from the list the desktop sidebar + reads. Inside somebody else's record Notifications and Admin + drop out — every route behind them refuses under a switch — + and Settings stays only where the record has Settings pages, + pointing at the first one the shell opens. The switcher + below is how the person gets back. */} + {utilityItems.map((item) => { + const Icon = item.icon; + return ( + + + + {t(item.tKey)} - - - - {t("nav.notifications")} - - - {isAdmin && ( - - - - {t("nav.admin")} - - - )} - + ); + })} + {!sharedRecord && isAdmin && ( + + + + {t("nav.admin")} + + )} {/* v1.4.36 W4e — About moved into the Admin Console (`/admin/about`). The dropdown entry was redundant diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 2012bc718..cdef9f561 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -49,7 +49,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import type { ModuleKey } from "@/lib/modules/registry"; -import { classifySettingsDestination } from "@/lib/record-settings"; +import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, @@ -448,31 +448,16 @@ export function SettingsShell({ // Keep `allVisibleSections` for the active heading so a direct unavailable // link identifies itself before the gate explains why it cannot open. // - // * A managed profile shows its guardian configuration AND the record - // content a MANAGE holder may write — the guardian holds MANAGE, so the - // second set is theirs as well. - // * An ordinary shared record at MANAGE shows only the record content. A - // delegate manages somebody's health record, not their account: modules, - // thresholds and notification routing stay with the owner. - // * Every other shared record shows nothing here, because it reaches no - // Settings destination at all. + // Which destinations a record lists is answered by + // `isSettingsDestinationListedForRecord`, the same predicate the app + // navigation reads to decide whether to offer a Settings entry at all and + // where it lands. Its docblock carries the per-kind rules. const visibleSections = activeRecord === null ? allVisibleSections - : allVisibleSections.filter((section) => { - const kind = classifySettingsDestination(section.slug).kind; - // Every destination on either list needs MANAGE, so the level is - // checked once for both record kinds rather than only for the adult - // one. A guardian grant is always MANAGE today, which is exactly why - // the check was easy to leave out — and a managed entry that ever - // arrived below it would have listed destinations the section gate - // refuses, sending somebody to a page that explains it cannot open. - if (activeRecord.level !== "manage") return false; - if (activeRecord.recordKind === "managed") { - return kind === "managed-guardian" || kind === "manage-writable"; - } - return kind === "manage-writable"; - }); + : allVisibleSections.filter((section) => + isSettingsDestinationListedForRecord(section.slug, activeRecord), + ); const activeSection = allVisibleSections.find( (section) => section.slug === activeSlug, ); diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index 0e532fd16..6c606479a 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -1,4 +1,11 @@ -import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; +import { + SETTINGS_SECTION_SLUGS, + type SettingsSectionSlug, +} from "@/components/settings/section-slugs"; +import type { + AccountAccessLevel, + AccountRecordKind, +} from "@/lib/sharing/account-access-view"; export type SettingsDestinationKind = | "personal" @@ -138,3 +145,69 @@ export function isManageDelegateSettingsDestination( ): boolean { return classifySettingsDestination(destination).kind === "manage-writable"; } + +/** + * The record a Settings listing is drawn for: the server-resolved kind of the + * record on screen and the level of the grant that opened it. `level` is null + * in one's own record and in a context the client could not prove. + */ +export interface SettingsRecordContext { + recordKind: AccountRecordKind; + level: AccountAccessLevel | null; +} + +/** + * Does the Settings shell list this destination inside a shared record. + * + * The one answer both the shell's section list and the app navigation read, + * so the navigation cannot offer a Settings entry the shell has nothing + * behind, nor withhold one the shell would list. + * + * * A managed profile at MANAGE lists its guardian configuration and the + * record content a MANAGE holder may write. The guardian holds MANAGE, so + * the second set is theirs as well. + * * An ordinary shared record at MANAGE lists only the record content. A + * delegate manages somebody's health record, not their account: modules, + * thresholds and notification routing stay with the owner. + * * Every other context lists nothing. + * + * Every destination on either list needs MANAGE, so the level is checked once + * for both record kinds. A guardian grant is always MANAGE today, which is + * exactly why the check was easy to leave out, and a managed entry that ever + * arrived below it would have listed destinations the section gate refuses. + * + * Paint only. The section gate still refuses any direct URL on its own. + */ +export function isSettingsDestinationListedForRecord( + destination: string, + record: SettingsRecordContext, +): boolean { + if (record.level !== "manage") return false; + const kind = classifySettingsDestination(destination).kind; + if (record.recordKind === "managed") { + return kind === "managed-guardian" || kind === "manage-writable"; + } + if (record.recordKind === "shared") return kind === "manage-writable"; + return false; +} + +/** + * The Settings page a navigation entry opens inside a shared record, or null + * when the shell would list nothing there and no entry should be offered. + * + * `order` is the order the shell lists its sections in. The navigation passes + * the slug registry, whose order differs from the shell's in general; the + * parity test in `nav-model-shared-record.test.ts` holds the first answer + * equal to the first section the shell actually lists, for every record kind, + * so a reorder on either side fails there rather than landing somebody on a + * page the shell does not open with. + */ +export function recordSettingsLandingDestination( + record: SettingsRecordContext, + order: readonly SettingsSectionSlug[] = SETTINGS_SECTION_SLUGS, +): SettingsSectionSlug | null { + return ( + order.find((slug) => isSettingsDestinationListedForRecord(slug, record)) ?? + null + ); +}