diff --git a/apps/admin/README.md b/apps/admin/README.md index 7c8333ec..9de9102b 100644 --- a/apps/admin/README.md +++ b/apps/admin/README.md @@ -7,6 +7,16 @@ Traefik ingress also redirects HTTP to HTTPS. See [configuration, authentication, service boundaries and commands](../../docs/admin.md). +## User details + +The user Details tab shows reader-profile fields and Dev Card styling only when +the account has a configured username. Claimed profiles remain inspectable by +admins when public visibility is disabled. Accounts without a username still +show account information, reading activity, personalization, and preferences. + +Validate these states with `npm run admin:test` and, after `npm run admin:build`, +`node apps/admin/tests/browser/user-must-reads.mjs`. + ## Shared table columns `DataTableColumn` and resource `ColumnSpec` accept a `kind` that selects a shared diff --git a/apps/admin/src/components/organisms/user-details.tsx b/apps/admin/src/components/organisms/user-details.tsx index 5ddcc686..67a4c1aa 100644 --- a/apps/admin/src/components/organisms/user-details.tsx +++ b/apps/admin/src/components/organisms/user-details.tsx @@ -143,6 +143,7 @@ function UserDisclosure({ title, children }: { title: string; children: React.Re } export function UserDetailsOverview({ user }: { user: AdminUserDetail }) { + const hasUsername = Boolean(user.username?.trim()); return (
@@ -183,30 +184,32 @@ export function UserDetailsOverview({ user }: { user: AdminUserDetail }) { }, ]} /> - -
+ {hasUsername && } +
- - item.name)} />, - }, - { - label: "Featured stats", - value: , - }, - ]} - /> - + {hasUsername && ( + + item.name)} />, + }, + { + label: "Featured stats", + value: , + }, + ]} + /> + + )}
{ @@ -67,7 +68,7 @@ const fixture = createServer((req, res) => { else if (path.endsWith("/settings")) body = { appearance: { theme: "light" }, defaults: { refresh_seconds: 0 } }; else if (path.endsWith("/notifications/config")) body = { enabled: false }; - else if (path === `/v1/admin/users/${userId}`) body = user; + else if (path === `/v1/admin/users/${userId}`) body = { ...user, username: configuredUsername }; else if (path === `/v1/admin/users/${userId}/interests`) body = { items: [], total: 0, limit: 5, offset: 0 }; else if (path === `/v1/admin/users/${userId}/must-reads`) { @@ -194,6 +195,7 @@ try { } await page.goto(`${origin}/users/${userId}`); await page.getByText("Account", { exact: true }).waitFor(); + await page.getByText("Reader profile", { exact: true }).waitFor(); for (const title of ["Dev Card styling", "Reader preferences", "Technical identifiers"]) { const disclosure = page .locator("details") @@ -217,6 +219,55 @@ try { await page.screenshot({ path: `${output}/details-${theme}-${width}.png`, fullPage: true }); } } + configuredUsername = null; + await page.reload(); + await page.getByText("Account", { exact: true }).waitFor(); + for (const title of ["Reader profile", "Dev Card styling", "Dev Card"]) { + assert.equal(await page.getByText(title, { exact: true }).count(), 0); + } + for (const title of ["Reading activity", "Recommendation refresh", "Personalization"]) { + assert.equal(await page.getByText(title, { exact: true }).count(), 1); + } + for (const title of ["Reader preferences", "Technical identifiers"]) { + const disclosure = page + .locator("details") + .filter({ has: page.locator("summary", { hasText: title }) }); + assert.equal(await disclosure.evaluate((node) => node.open), false); + await disclosure.locator("summary").focus(); + await page.keyboard.press("Enter"); + assert.equal(await disclosure.evaluate((node) => node.open), true); + await disclosure.locator("summary").click(); + } + for (const theme of ["light", "dark"]) { + await page + .locator("html") + .evaluate((node, theme) => node.classList.toggle("dark", theme === "dark"), theme); + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 1000 }); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + false, + ); + if (width === 1440) { + const refreshPanel = await page + .getByText("Recommendation refresh", { exact: true }) + .locator('xpath=ancestor::div[@data-slot="card"]') + .boundingBox(); + const activityPanel = await page + .getByText("Personalization", { exact: true }) + .locator('xpath=ancestor::div[@data-slot="card"]') + .boundingBox(); + assert.ok(refreshPanel && activityPanel); + assert.ok(Math.abs(refreshPanel.y - activityPanel.y) < 1); + assert.ok(refreshPanel.x < activityPanel.x); + } + await page.screenshot({ path: `${output}/unclaimed-${theme}-${width}.png`, fullPage: true }); + } + } + configuredUsername = user.username; + await page.reload(); + await page.getByText("Reader profile", { exact: true }).waitFor(); + assert.equal(await page.getByText("Dev Card styling", { exact: true }).count(), 1); await page.goto(`${origin}/users/${userId}/analysis`); mode = "empty"; await page.reload(); @@ -240,7 +291,7 @@ try { ); assert.deepEqual(errors, []); console.log( - "Admin Must Reads: saved picks, reasons, timezone, read progress, empty states, retry, and responsive themes passed.", + "Admin users: claimed/unclaimed profile visibility, keyboard disclosures, responsive themes, and Must Reads passed.", ); } catch (error) { console.error(logs); diff --git a/apps/admin/tests/users.test.tsx b/apps/admin/tests/users.test.tsx index 966986e5..67ef0d25 100644 --- a/apps/admin/tests/users.test.tsx +++ b/apps/admin/tests/users.test.tsx @@ -1,10 +1,15 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { renderAdmin } from "./render-admin"; import { ResourceList } from "@/components/organisms/resource-list"; import { ResourceDetail } from "@/components/organisms/resource-detail"; -import { UserRecords, UserAnalysis, UserAnalysisAction } from "@/components/organisms/user-details"; +import { + UserRecords, + UserAnalysis, + UserAnalysisAction, + UserDetailsOverview, +} from "@/components/organisms/user-details"; import { getRecord, listRecords, listUserRecords } from "@/lib/resource-api"; import { adminRouteTitle } from "@/lib/page-titles"; import type { AdminUserDetail } from "@/lib/api/generated/models"; @@ -135,6 +140,46 @@ it("shows account information and links to existing-style detail sections", asyn expect(screen.queryByRole("link", { name: "Logs" })).toBeNull(); expect(screen.queryByRole("link", { name: "Open in graph" })).toBeNull(); }); + +it.each([null, undefined, "", " "])( + "omits profile and Dev Card details when the username is %j", + async (username) => { + vi.mocked(getRecord).mockResolvedValue({ ...user, username }); + renderAdmin(); + await screen.findByText("Account"); + expect(screen.queryByText("Reader profile")).toBeNull(); + expect(screen.queryByText("Dev Card styling")).toBeNull(); + expect(screen.queryByText("Dev Card")).toBeNull(); + expect(screen.queryByRole("link", { name: "Website" })).toBeNull(); + expect(screen.queryByText("London")).toBeNull(); + for (const title of [ + "Reading activity", + "Recommendation refresh", + "Personalization", + "Reader preferences", + "Technical identifiers", + ]) { + expect(screen.getByText(title)).toBeTruthy(); + } + expect(screen.getByText("Longest streak")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Reading days" })).toBeTruthy(); + }, +); + +it("shows claimed profile details for private profiles and updates when the username changes", () => { + const view = render(); + expect(screen.getByText("Reader profile")).toBeTruthy(); + expect(screen.getByText("Hidden")).toBeTruthy(); + expect(screen.getByText("Dev Card styling")).toBeTruthy(); + view.rerender(); + expect(screen.queryByText("Reader profile")).toBeNull(); + expect(screen.queryByText("Dev Card styling")).toBeNull(); + view.rerender(); + expect(screen.getByText("Reader profile")).toBeTruthy(); + expect(screen.getByText("Dev Card styling")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Website" })).toBeTruthy(); +}); + it("labels stale prepared results and fetches only the selected user section", async () => { renderAdmin( ,