Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 23 additions & 20 deletions apps/admin/src/components/organisms/user-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="space-y-6">
<div className="grid items-start gap-6 lg:grid-cols-2">
Expand Down Expand Up @@ -183,30 +184,32 @@ export function UserDetailsOverview({ user }: { user: AdminUserDetail }) {
},
]}
/>
<UserProfileDetails user={user} />
<div className="min-w-0 space-y-6">
{hasUsername && <UserProfileDetails user={user} />}
<div className={hasUsername ? "min-w-0 space-y-6" : "contents"}>
<UserFeedStatus user={user} />
<UserActivitySummary user={user} />
</div>
</div>
<UserDisclosure title="Dev Card styling">
<InfoPanel
title="Dev Card"
fields={[
{ label: "Theme", value: humanize(user.dev_card.theme ?? "classic") },
{ label: "Accent", value: humanize(user.dev_card.accent ?? "default") },
{ label: "Motion", value: humanize(user.dev_card.motion ?? "animated") },
{
label: "Technologies",
value: <DataValue value={user.dev_card_technologies.map((item) => item.name)} />,
},
{
label: "Featured stats",
value: <DataValue value={(user.dev_card.stats ?? []).map(humanize)} />,
},
]}
/>
</UserDisclosure>
{hasUsername && (
<UserDisclosure title="Dev Card styling">
<InfoPanel
title="Dev Card"
fields={[
{ label: "Theme", value: humanize(user.dev_card.theme ?? "classic") },
{ label: "Accent", value: humanize(user.dev_card.accent ?? "default") },
{ label: "Motion", value: humanize(user.dev_card.motion ?? "animated") },
{
label: "Technologies",
value: <DataValue value={user.dev_card_technologies.map((item) => item.name)} />,
},
{
label: "Featured stats",
value: <DataValue value={(user.dev_card.stats ?? []).map(humanize)} />,
},
]}
/>
</UserDisclosure>
)}
<UserDisclosure title="Reader preferences">
<div className="grid items-start gap-4 lg:grid-cols-2">
<InfoPanel
Expand Down
55 changes: 53 additions & 2 deletions apps/admin/tests/browser/user-must-reads.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const user = {
notification_preferences: { show_badge: true, sound: false },
};
let mode = "ready";
let configuredUsername = user.username;
let rejectOnce = false;
const requests = [];
const fixture = createServer((req, res) => {
Expand All @@ -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`) {
Expand Down Expand Up @@ -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")
Expand All @@ -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();
Expand All @@ -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);
Expand Down
49 changes: 47 additions & 2 deletions apps/admin/tests/users.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(<ResourceDetail resource="users" id="user-1" />);
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(<UserDetailsOverview user={{ ...user, profile_public: false }} />);
expect(screen.getByText("Reader profile")).toBeTruthy();
expect(screen.getByText("Hidden")).toBeTruthy();
expect(screen.getByText("Dev Card styling")).toBeTruthy();
view.rerender(<UserDetailsOverview user={{ ...user, username: null }} />);
expect(screen.queryByText("Reader profile")).toBeNull();
expect(screen.queryByText("Dev Card styling")).toBeNull();
view.rerender(<UserDetailsOverview user={user} />);
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(
<UserRecords user={{ ...user, feed_status: "refreshing" }} section="recommendations" />,
Expand Down
Loading