Stream C from docs/information-architecture.md. The navbar's account
cluster (<username> + Settings + Logout) was three controls for the
same concept; the cluster now collapses behind an avatar dropdown.
On mobile, all primary nav and account controls move into a
hamburger drawer so the top bar stays at logo + bell + hamburger
without wrapping at small viewports.
- New apps/journal/app/components/Avatar.tsx — initials-only avatar
(no image-upload story yet; the component is the single place to
add image fallback when one lands).
- New apps/journal/app/components/AccountDropdown.tsx — click the
avatar trigger to reveal Profile / Settings / Log Out menuitems.
Click-outside + Escape-to-close. role="menu" + role="menuitem" for
a11y.
- New apps/journal/app/components/MobileNavMenu.tsx — hamburger
trigger + slide-out drawer at md and below. Contains all primary
nav (Feed, Explore, Routes, Activities, Notifications) plus the
account cluster (Profile, Settings, Log Out). Auto-closes on
navigation; locks body scroll while open.
- apps/journal/app/root.tsx — refactor NavBar:
* Brand + primary nav links hide at md and below (drawer covers
them).
* Right cluster: bell on every viewport; avatar dropdown on
desktop, hamburger on mobile.
* Loader exposes user.displayName so Avatar can compute initials.
* Drops the now-unused Form import; Form moved into the
dropdown/drawer components.
E2E tests updated:
- e2e/auth.test.ts — logout helper opens the avatar dropdown via
aria-label = displayName||username, then clicks the Log Out
menuitem. Username assertions in nav switch from getByText to
getByRole("button", {name: username}) (the avatar button).
- e2e/settings.test.ts — "settings link visible in nav" opens the
avatar dropdown first, then asserts the Settings menuitem.
i18n: nav.openMenu, nav.closeMenu keys for the hamburger trigger
labels (EN + DE).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
|
|
|
|
// Virtual authenticator helpers
|
|
async function setupVirtualAuthenticator(cdp: CDPSession) {
|
|
await cdp.send("WebAuthn.enable");
|
|
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
|
options: {
|
|
protocol: "ctap2",
|
|
transport: "internal",
|
|
hasResidentKey: true,
|
|
hasUserVerification: true,
|
|
isUserVerified: true,
|
|
},
|
|
});
|
|
return authenticatorId;
|
|
}
|
|
|
|
async function removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) {
|
|
await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
|
|
await cdp.send("WebAuthn.disable");
|
|
}
|
|
|
|
async function registerUser(page: Page, email: string, username: string) {
|
|
await page.goto("/auth/register");
|
|
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
|
|
await page.getByLabel("Email").click();
|
|
await page.getByLabel("Email").fill(email);
|
|
await page.getByLabel("Username").click();
|
|
await page.getByLabel("Username").fill(username);
|
|
// Verify both fields retained values before submitting
|
|
await expect(page.getByLabel("Email")).toHaveValue(email);
|
|
await expect(page.getByLabel("Username")).toHaveValue(username);
|
|
// Accept the Terms of Service (required)
|
|
await page.getByRole("checkbox").check();
|
|
await page.getByRole("button", { name: /Register with Passkey/ }).click();
|
|
}
|
|
|
|
async function logout(page: Page, accountLabel: string) {
|
|
// The account cluster lives inside an avatar dropdown now. Click the
|
|
// avatar (its aria-label is displayName || username), then click the
|
|
// Log Out menuitem inside the popup.
|
|
await page.getByRole("navigation").getByRole("button", { name: accountLabel }).click();
|
|
await page.getByRole("menuitem", { name: "Log Out" }).click();
|
|
await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
test.describe("Passkey Authentication", () => {
|
|
test("register with passkey and sign in", async ({ page }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `test-${Date.now()}@example.com`;
|
|
const username = `testuser${Date.now()}`;
|
|
|
|
// Register
|
|
await registerUser(page, email, username);
|
|
await expect(page).toHaveURL("/", { timeout: 10000 });
|
|
// The avatar button's aria-label is the displayName or username;
|
|
// for a freshly-registered user with no displayName set, that's
|
|
// the username.
|
|
await expect(page.getByRole("navigation").getByRole("button", { name: username })).toBeVisible({ timeout: 5000 });
|
|
|
|
// Log out
|
|
await logout(page, username);
|
|
|
|
// Sign in with passkey
|
|
await page.goto("/auth/login");
|
|
await page.getByRole("button", { name: /Sign in with Passkey/ }).click();
|
|
await expect(page).toHaveURL("/", { timeout: 10000 });
|
|
await expect(page.getByRole("navigation").getByRole("button", { name: username })).toBeVisible({ timeout: 5000 });
|
|
|
|
await removeVirtualAuthenticator(cdp, authenticatorId);
|
|
});
|
|
|
|
test("passkey login fails with no registered credential", async ({ page }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
|
|
|
await page.goto("/auth/login");
|
|
await page.getByRole("button", { name: /Sign in with Passkey/ }).click();
|
|
await expect(page.getByText(/No passkey found/i)).toBeVisible({ timeout: 10000 });
|
|
|
|
await removeVirtualAuthenticator(cdp, authenticatorId);
|
|
});
|
|
|
|
test("register rejects duplicate email", async ({ page }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
|
|
|
const email = `dup-${Date.now()}@example.com`;
|
|
const firstUsername = `first${Date.now()}`;
|
|
|
|
// Register first user
|
|
await registerUser(page, email, firstUsername);
|
|
await expect(page).toHaveURL("/", { timeout: 10000 });
|
|
await logout(page, firstUsername);
|
|
|
|
// Try to register with same email
|
|
await registerUser(page, email, `second${Date.now()}`);
|
|
await expect(page.getByText(/already in use/i)).toBeVisible({ timeout: 10000 });
|
|
|
|
await removeVirtualAuthenticator(cdp, authenticatorId);
|
|
});
|
|
|
|
test("register rejects duplicate username", async ({ page }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const authenticatorId = await setupVirtualAuthenticator(cdp);
|
|
|
|
const username = `uniq${Date.now()}`;
|
|
|
|
// Register first user
|
|
await registerUser(page, `first-${Date.now()}@example.com`, username);
|
|
await expect(page).toHaveURL("/", { timeout: 10000 });
|
|
await logout(page, username);
|
|
|
|
// Try to register with same username
|
|
await registerUser(page, `second-${Date.now()}@example.com`, username);
|
|
await expect(page.getByText(/already taken/i)).toBeVisible({ timeout: 10000 });
|
|
|
|
await removeVirtualAuthenticator(cdp, authenticatorId);
|
|
});
|
|
});
|