* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak) The last remaining P0 ship-blocker from .convoys/ship-readiness.md. components/Layout.js line 562 defaults the user prop to a real email address (me@randallstillwell.com); any page that renders Layout without passing user explicitly impersonates the maintainer. Scope: components/Layout.js + audit of 17 pages that import Layout (grep-confirmed list in convoy file). Single PR likely. Auditor cohort skipped (no design-system, IA, or browser-smoke surface). Architect to address: - Q1: logged-out rendering branch design (navbar, mobile-nav, auth-only items treatment) - Q2: page audit triage into always-auth / public-or-auth / anonymous-allowed buckets - Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out) - Q4: whether to add vitest coverage for the logged-out branch (recommend yes — small surface, high regression protection) Hard out-of-scope: branding (pick-a-name), auth-provider collapse (single-auth-provider), Layout god-component split (god-component-split). depends_on: bump-next-js (shipped), fix-auth-bypass (shipped), drop-public-setup (shipped) addresses: P0 #7 from .convoys/ship-readiness.md parent: ship-readiness Co-authored-by: Cursor <cursoragent@cursor.com> * architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit) 2 briefs, single PR. ~12 files net (down from the 18 in the original scope — 10 of the 17 Layout-importing pages already pass user explicitly). Brief 1: components/Layout.js default user=null + Sign-in CTA branch in UserProfileDropdown when logged out. Adds first jsdom test in the repo at test/components/Layout.test.js (Decision D2) with 5 regression-lock assertions. devDeps: jsdom@^29, @testing-library/react@^16. Brief 2: page audit sweep — 7 pages need code changes: - Pass user={user} to Layout: scanner.js, deck-builder.js (×4), deck/[id].js (×3), decks.js (×3) - Replace page-level useState({email: 'me@...'}) → useState(null) + null-guards: profile.js, settings.js - Replace hardcoded const user = {email: 'me@...'} with useAuth(): card/[id].js Discovered second anti-pattern: profile.js, settings.js, card/[id].js seed page-level state with the maintainer email. Folded into Brief 2 since success metric "no real email address remains in any component default-prop" reads naturally to include page-level seed values. Decisions: A1 — Sign-in CTA replaces avatar+email+dropdown when user===null; hides auth-only dropdown (Profile/Settings/Logout/Admin); keeps public + community nav visible B — Per-page bucket assignment (10 already correct, 7 need fix); full per-page table with justification in convoy file C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page sweep depends on Brief 1). C1 buries the conceptual change under mechanical edits; C3 is over-orchestrated for this scope D2 — vitest lock-in; first jsdom test in repo; same negative-regression style as test/lib/permission-middleware.test.js (synthetic-admin shape). devDeps jsdom + @testing-library/react Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky pages — mitigated by audit-pass mandate + manual smoke). MobileNavigation deliberately NOT folded in: its user prop is dead code (never reads user.*); different bug class; cleanup queued separately to avoid scope expansion. Flagged-but-deferred: - 4 pages still import useAuth from lib/auth-context.js → single-auth-provider (queued P1 #9) - Layout headers still render "Deck Hearth" / "DH" branding → pick-a-name (queued P1 #12) - MobileNavigation dead user prop → cleanup-mobile-nav-dead-props or fold into god-component-split addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker) parent: ship-readiness Co-authored-by: Cursor <cursoragent@cursor.com> * feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user) Closes the source-side half of P0 #7 from .convoys/ship-readiness.md. The page-side sweep (Brief 2) follows in a separate commit. components/Layout.js: - Default user prop is now null (was hardcoded to { email: 'me@randallstillwell.com', role: 'user' }) - UserProfileDropdown renders a "Sign in" link to /login when user === null instead of the maintainer's email + auth-only menu items (Decision A1) - All user.* accesses guarded with optional chaining or null checks - useState hook stays above the new null-user early return to satisfy rules-of-hooks (boot-the-brief caught this on the first try; see AGENTS.md Gotcha #11.5) test/components/Layout.test.js (new): - First jsdom test in the repo (Decision D2) - 5 regression-lock assertions: no maintainer email ever rendered (prop omitted, prop=null), Sign-in link exists with href=/login, supplied email renders when prop is set, no "Guest" placeholder (locks A1 copy choice) - Mocks next/link, next/router (prefetch, replace, events, query), and theme-context.useTheme for jsdom safety under Next 16 package.json + package-lock.json: - Add jsdom@^29 and @testing-library/react@^16 to devDependencies - @testing-library/dom@^10 added explicitly (peer auto-install skipped it under npm 11; brief anticipated this fallback) vitest.config.js (deviation from brief — see PR description): - Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can parse JSX in .js files. Required to import any React component written in the repo's Next.js pages-router .js convention (AGENTS.md Gotcha #9). The brief said "no change" to this file, but JSX-in-.js parsing is a hard prerequisite for the new test to import components/Layout.js — the alternatives (rename test to .test.jsx; rewrite test in React.createElement) either break the test glob or still hit the same Layout.js parse failure. Other tests are unaffected (they import non-JSX modules). Smoke output: see PR description. addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker) Co-authored-by: Cursor <cursoragent@cursor.com> * feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user) Closes the page-side half of P0 #7 from .convoys/ship-readiness.md. Brief 1 (commit ddf8fd2) handled the Layout-side fix. Per the architect's per-page bucket table (Decision B in .convoys/fix-layout-default-user.md), 7 pages needed code changes; the other 10 of 17 Layout-importing pages already pass `user` correctly. Pass user={user} to Layout (4 pages, 11 call sites): - pages/scanner.js (1 call) - pages/decks.js (3 calls) - pages/deck-builder.js (4 calls) - pages/deck/[id].js (3 calls) (All four still import useAuth from lib/auth-context.js — that's intentional and stays as-is until the single-auth-provider convoy collapses the three parallel auth surfaces.) Replace leaky page-level seed values with useState(null) + null guards (2 pages, R2 mitigation): - pages/profile.js: useState({email: 'me@...', role: 'user', ...}) → useState(null) + ?. on every sync user.* read + early-return guards in getDisplayName/getInitials + conditional render around the "Member since" block so formatDate(undefined) never runs - pages/settings.js: same pattern (single user.email reader guarded) Replace hardcoded const with useAuth from lib/use-auth.js (1 page): - pages/card/[id].js: const user = {email: 'me@...'} → const { user } = useAuth() (called unconditionally at the top of the component; rules-of-hooks safe) Verification: - grep 'me@randallstillwell.com' pages/ → 0 hits - 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1) - npm run lint matches baseline (128 problems pre, 128 post; verified via git stash before/after) - Manual static read-through of every diff; ReadLints clean on the 7 files - Dev-server smoke: /cards anonymous returned HTTP 200 with 0 'me@randallstillwell' matches before the user's shared dev server became unresponsive mid-session (same dev-server-shared-by-user constraint flagged in Brief 1); interactive logged-in smoke is parent/operator gated Flagged-but-deferred (untouched per scope): - 4 pages still import useAuth from lib/auth-context.js → single-auth-provider (queued P1 #9) - components/MobileNavigation.js still receives dead user prop → cleanup-mobile-nav-dead-props (or fold into god-component-split) addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker) Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7e972546b7
commit
ca302a89c1
15 changed files with 1559 additions and 58 deletions
285
.convoys/fix-layout-default-user.md
Normal file
285
.convoys/fix-layout-default-user.md
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
---
|
||||
name: fix-layout-default-user
|
||||
classification: convoy
|
||||
success_metric: |
|
||||
components/Layout.js's user prop defaults to null. Every page in pages/**
|
||||
that renders Layout either passes a user prop explicitly OR relies on the
|
||||
new logged-out rendering branch. No real email address remains in any
|
||||
component default-prop. Manual smoke: load an unauthenticated session on
|
||||
a page that previously impersonated the maintainer; confirm the navbar /
|
||||
profile dropdown reads "Sign in" rather than "me@randallstillwell.com".
|
||||
skip:
|
||||
- role-design-system-auditor # no design-token changes
|
||||
- role-ia-architect # no URL / IA changes
|
||||
- browser-smoke # local smoke is fine for this scope
|
||||
status: open
|
||||
created: 2026-05-23
|
||||
parent: ship-readiness
|
||||
addresses: P0 #7
|
||||
depends_on:
|
||||
- bump-next-js (shipped)
|
||||
- fix-auth-bypass (shipped)
|
||||
- drop-public-setup (shipped)
|
||||
---
|
||||
|
||||
# Fix Layout default user
|
||||
|
||||
Close P0 #7 from `.convoys/ship-readiness.md` (the **last** remaining P0
|
||||
ship-blocker). `components/Layout.js` line 562 defaults the `user` prop to
|
||||
`{ email: 'me@randallstillwell.com', role: 'user' }` — any page that renders
|
||||
`Layout` without passing `user` explicitly displays the maintainer's real
|
||||
email and impersonates them as the logged-in user.
|
||||
|
||||
## Scope (verbatim from ship-readiness P0 #7)
|
||||
|
||||
- **`components/Layout.js`** — change the `Layout({ children, user = {...} })`
|
||||
default to `user = null`. Add a logged-out rendering branch (navbar /
|
||||
profile dropdown / mobile menu) that handles `user === null` cleanly —
|
||||
typically "Sign in" CTA replacing the user avatar + dropdown.
|
||||
- **17 pages in `pages/**`** that import Layout (confirmed via grep):
|
||||
```
|
||||
pages/scanner.js
|
||||
pages/collection/[identifier].js
|
||||
pages/card/[id].js
|
||||
pages/my-cards.js
|
||||
pages/cards.js
|
||||
pages/deck-builder.js
|
||||
pages/deck/[id].js
|
||||
pages/decks.js
|
||||
pages/dashboard.js
|
||||
pages/community/collections.js
|
||||
pages/collections.js
|
||||
pages/settings.js
|
||||
pages/profile.js
|
||||
pages/invite/decline.js
|
||||
pages/invite/accept.js
|
||||
pages/admin/card-import.js
|
||||
pages/admin/card-editor.js
|
||||
```
|
||||
For each: confirm it passes `user` explicitly OR triage that it should
|
||||
use the new logged-out branch (e.g. public pages like card/[id].js,
|
||||
community/collections.js may legitimately render Layout for anonymous
|
||||
visitors).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Branding** — Layout still renders "Deck Hearth" / "DH" while the rest of
|
||||
the repo says "TCG Vault". That mismatch is the queued `pick-a-name`
|
||||
convoy (P1 #12). Don't fix branding here.
|
||||
- **Three parallel auth providers** — Layout reads from one of
|
||||
`lib/auth-context.js` / `lib/admin-auth.js` / `lib/use-auth.js`. Collapsing
|
||||
them is the queued `single-auth-provider` convoy (P1 #9).
|
||||
- **God-component split** — `components/Layout.js` is 700+ lines. Splitting
|
||||
is the queued `god-component-split` convoy (P2 #13). Touch only the user-
|
||||
prop default and the logged-out rendering branch in THIS convoy.
|
||||
- **`MobileNavigation`** — receives `user` from Layout. May need a similar
|
||||
default-prop fix if it has the same anti-pattern. Audit during architect
|
||||
pass and decide whether to fold in or queue separately.
|
||||
- **`AGENTS.md` Gotcha #8** — will be marked RESOLVED in the post-convoy
|
||||
doc-writer pass; do not pre-emptively edit AGENTS.md here.
|
||||
|
||||
## Architect's questions
|
||||
|
||||
1. **Logged-out rendering branch design.** When `user === null`, what should
|
||||
Layout render?
|
||||
- **Q1a:** Navbar / profile dropdown — replace the user avatar + email
|
||||
with a "Sign in" link to `/login`?
|
||||
- **Q1b:** Mobile bottom-nav — same treatment, or hide the user-only
|
||||
items entirely?
|
||||
- **Q1c:** Authenticated-only nav items (admin, settings, profile) —
|
||||
hide them, or show but link to `/login`?
|
||||
|
||||
2. **Page audit triage.** For each of the 17 pages, three buckets:
|
||||
- **Always-authenticated** (dashboard, my-cards, profile, settings,
|
||||
scanner, admin/*) — must pass `user` explicitly; pages without it
|
||||
should add it via `useAuth()`.
|
||||
- **Public-or-authenticated** (cards, card/[id], collection/[identifier],
|
||||
community/collections, deck/[id], collections, decks) — currently
|
||||
show different views based on auth; the Layout user prop should
|
||||
come from `useAuth()` either way.
|
||||
- **Anonymous-allowed** (invite/decline, invite/accept) — may
|
||||
legitimately render Layout without a user; rely on the new
|
||||
logged-out branch.
|
||||
|
||||
The architect should produce the exact bucket assignment per page and
|
||||
the brief should give the implementer the per-page instruction.
|
||||
|
||||
3. **Brief decomposition.** Three options:
|
||||
- **Single brief, one PR.** All 18 files (Layout + 17 pages) in one diff.
|
||||
Reviewable but big.
|
||||
- **Two briefs, one PR.** Brief 1: Layout change + logged-out rendering.
|
||||
Brief 2: page audit (depends on Brief 1). Both ship together.
|
||||
- **Fan-out by page bucket.** Brief 1: Layout change. Brief 2: always-auth
|
||||
pages. Brief 3: public-or-auth pages. Brief 4: anonymous-allowed pages.
|
||||
Multitask-friendly via worktrees.
|
||||
|
||||
**Recommend two briefs in one PR** for size + reviewability balance,
|
||||
unless the page audit reveals >10 files needing real changes (in which
|
||||
case fan-out makes sense).
|
||||
|
||||
4. **Test coverage.** Should this convoy add vitest tests that exercise
|
||||
Layout's logged-out branch? The fix-auth-bypass convoy added 16 auth
|
||||
tests (`test/lib/permission-middleware.test.js`); a similar lock-in for
|
||||
the user-prop default could prevent regression.
|
||||
|
||||
**Recommend yes** — a single test that asserts `Layout` renders the
|
||||
logged-out shape when `user === undefined` and `user === null` would
|
||||
catch any future regression that reintroduces the maintainer-email
|
||||
default. Trivial to write; high value.
|
||||
|
||||
## Expected size
|
||||
|
||||
1-2 briefs, ~18 files total (1 component, 17 pages). Single PR likely.
|
||||
|
||||
## Architecture
|
||||
|
||||
### File plan
|
||||
|
||||
| File | Action | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `components/Layout.js` | modified | Default `user` to `null`. Replace `UserProfileDropdown`'s avatar+email+dropdown with a "Sign in" CTA (inline) when `user === null`. The other null-safe paths (`NavigationContent`'s `authenticatedNavigation`, `myCollectionNavigation`, `adminNavigation`) already do the right thing today; do not touch them. |
|
||||
| `vitest.config.js` | modified | No global change to `environment`; the new test file uses a per-file `// @vitest-environment jsdom` directive. The only edit here is bumping `include: ['test/**/*.test.js']` if the implementer wants test files under `test/components/` (already covered by the existing glob — verify only). |
|
||||
| `package.json` | modified | Add `jsdom` and `@testing-library/react` to `devDependencies` so the new component test can render the JSX tree. No runtime deps. |
|
||||
| `package-lock.json` | modified | Regenerated by `npm install`. |
|
||||
| `test/components/Layout.test.js` | new | Locks in the contract: `<Layout>` (no prop) and `<Layout user={null}>` MUST NOT render `me@randallstillwell.com`; MUST render a "Sign in" affordance; `<Layout user={…}>` MUST render the supplied email. Negative regression against the maintainer-email default. |
|
||||
| `pages/scanner.js` | modified | Pass `user={user}` to `<Layout>` (line 333). `useAuth` from `lib/auth-context` is already imported. |
|
||||
| `pages/deck-builder.js` | modified | Pass `user={user}` to all four `<Layout>` calls. `useAuth` from `lib/auth-context` already imported. |
|
||||
| `pages/deck/[id].js` | modified | Pass `user={user}` to all three `<Layout>` calls. `useAuth` from `lib/auth-context` already imported. |
|
||||
| `pages/decks.js` | modified | Pass `user={user}` to all three `<Layout>` calls. `useAuth` from `lib/auth-context` already imported. |
|
||||
| `pages/profile.js` | modified | Page-level `useState` initializer at line 10 hardcodes `email: 'me@randallstillwell.com', role: 'admin'`. Replace the initial value with `null`. Wrap `user.*` reads in `?.` (the page already runs after `loadUserProfile()` resolves; the few sync reads need null-guards). Add `useAuth()` from `lib/use-auth.js` only if the implementer prefers a single source — but the simpler fix is `useState(null)` since the API call already overwrites the state. |
|
||||
| `pages/settings.js` | modified | Same shape as `profile.js`: replace `useState({ email: 'me@…', role: 'admin' })` with `useState(null)`. The component already redirects to `/login` if no token (line 53-55), so the null window is the loading flash. |
|
||||
| `pages/card/[id].js` | modified | Replace the hardcoded `const user = { email: 'me@…', role: 'user' }` (lines 13-16) with `const { user } = useAuth()` from `lib/use-auth.js`. This is a public-or-authenticated page (community card detail), so `user === null` is a legitimate state. |
|
||||
|
||||
The 10 remaining pages (`pages/dashboard.js`, `pages/my-cards.js`, `pages/cards.js`, `pages/collection/[identifier].js`, `pages/community/collections.js`, `pages/collections.js`, `pages/invite/accept.js`, `pages/invite/decline.js`, `pages/admin/card-import.js`, `pages/admin/card-editor.js`) already pass `user` correctly; **no changes** there. They are listed in the per-bucket assignment below for the audit record.
|
||||
|
||||
### API surface
|
||||
|
||||
None. This is a frontend component change.
|
||||
|
||||
### Schema diff
|
||||
|
||||
None.
|
||||
|
||||
### Test plan
|
||||
|
||||
- **Unit (new):** `test/components/Layout.test.js` (Decision D = D2). Per-file `// @vitest-environment jsdom`. Mocks `next/router` (`useRouter` → `{ pathname: '/' }`) and `lib/theme-context` (`useTheme` → `{ theme: 'light', toggleTheme: vi.fn() }`). Asserts:
|
||||
1. `<Layout>page</Layout>` (no `user` prop) renders a tree whose `textContent` does **not** include `me@randallstillwell.com`. (Direct regression on the bug shape.)
|
||||
2. `<Layout user={null}>page</Layout>` — same.
|
||||
3. `<Layout user={null}>page</Layout>` renders a "Sign in" affordance (`getByText('Sign in')`).
|
||||
4. `<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page</Layout>` renders `foo@bar.com` in the navbar.
|
||||
5. (Negative regression — explicit) `<Layout>page</Layout>`'s `textContent` does **not** include `Guest` (so we don't accidentally ship a "Guest" placeholder where Decision A says "Sign in" should live).
|
||||
- **No new tests** for the page audit changes. The Layout test catches the fix at the component boundary; verifying every page individually would duplicate that contract. Manual smoke covers the page-level fixes.
|
||||
- **Existing tests** (16 in `test/lib/permission-middleware.test.js`, 3 in `test/lib/auth-secret.test.js`, 5 in `test/api/auth-utils.test.js`) must remain green. None of them touch UI; this convoy will not regress them.
|
||||
- **Manual smoke** (in PR description):
|
||||
- With no `auth_token` in `localStorage` (logged out), visit `/scanner`, `/deck-builder`, `/decks`, `/deck/[any-id]`, `/profile`, `/settings`, `/card/[any-id]`. Confirm: navbar shows "Sign in" instead of `me@randallstillwell.com`, no "Profile / Settings / Logout" dropdown, public nav items still visible.
|
||||
- With `auth_token` set (logged in as a non-admin), visit each of the same pages. Confirm: navbar shows the real user's email, dropdown opens, "Profile / Settings / Logout" links visible, "Admin Panel" hidden.
|
||||
- Log in as admin. Confirm: "Admin Panel" link visible.
|
||||
- Visit `/invite/accept?token=anything` and `/invite/decline?token=anything` while logged out. Confirm: layout renders "Sign in", no maintainer email, no crashes (these pages explicitly pass `user={null}` and rely on the new branch).
|
||||
|
||||
### Risks
|
||||
|
||||
- **R1 — Loading-flash UX regression (low).** `useAuth()` returns `loading: true` with `user === null` until `/api/auth/verify` resolves. During that flash (< 200ms in dev, typically faster in prod), Layout shows "Sign in" before swapping to the authenticated shape. **Mitigation:** This is the same flash that `ProtectedRoute` and `AdminProtected` already produce; their loading branches render `<Layout user={null}>` today (see `components/ProtectedRoute.js` line 29, `components/AdminProtected.js` line 55). The fix keeps the existing UX contract; document in Brief 1 so the implementer doesn't try to "improve" it with a loading skeleton (out of scope).
|
||||
- **R2 — Page-level useState initializer regressions (medium).** `pages/profile.js`, `pages/settings.js`, and `pages/card/[id].js` initialize a hardcoded user object. Replacing the initializer with `null` means any sync code that reads `user.first_name`, `user.email`, `user.role`, etc. before the API resolves now hits a null-deref. **Mitigation:** Brief 2 enumerates the sync reads per page and adds `?.` / `?? defaults`. Vitest can't catch this at render time without a full mock harness; manual smoke is the gate. The implementer MUST exercise both the loading state and the loaded state on each of those three pages.
|
||||
- **R3 — `user.role === 'admin'` defaults flip (low).** `profile.js` and `settings.js` initialize `role: 'admin'`. Today, a non-admin user briefly sees admin chrome (Admin Panel link) during the load flash. After the fix, that flash shows "Sign in" (R1) until the API resolves, then the correct role takes over. The "ghost admin" was a worse UX bug already; the fix improves it. No mitigation needed.
|
||||
- **R4 — `UserProfileDropdown` Sign-in branch breaks the desktop+mobile-shared component (low).** The dropdown is rendered twice (desktop sidebar + mobile drawer). Both must show "Sign in" when logged out. **Mitigation:** Brief 1 makes the change inside `UserProfileDropdown` (a single function) — both call sites pick up the new behavior automatically.
|
||||
- **R5 — `vitest` test fragility (low).** The test asserts on rendered text. If a future change moves "Sign in" to an icon-only `aria-label`, `getByText` will fail. **Mitigation:** Test against `getByRole('link', { name: /sign in/i })` so the assertion survives icon-only refactors. Brief 1 specifies this query shape.
|
||||
- **R6 — `jsdom` + `@testing-library/react` adds devDeps (low).** Two new packages (~25 transitive deps; devDep only). **Mitigation:** Brief 1 declares the additions explicitly in `files:` and acceptance criteria; CI's existing `test:` job will pick up the new test automatically (the glob already includes `test/**/*.test.js`).
|
||||
- **R7 — Three parallel auth providers (existing, not introduced).** 4 of the 7 pages we touch import `useAuth` from `lib/auth-context.js` (the legacy provider) instead of the canonical `lib/use-auth.js`. Brief 2 explicitly says "do not change the auth import — keep the existing `useAuth` source." Migrating these to `lib/use-auth.js` is the queued `single-auth-provider` convoy's job. **Mitigation:** Brief 2 acceptance criteria includes a grep verification that `pages/scanner.js`, `pages/deck-builder.js`, `pages/deck/[id].js`, `pages/decks.js` still import from `lib/auth-context`.
|
||||
- **R8 — `MobileNavigation` receives `user` but ignores it (cosmetic, deferred).** `components/MobileNavigation.js` accepts `{ user, onMenuOpen }` but does not access any field of `user` (the bottom-bar items are static). The prop is dead. **Decision:** leave it alone in this convoy; flag as a tiny cleanup follow-up. If the implementer is tempted to delete the prop, they MUST stop — that's god-component-split / single-auth-provider territory.
|
||||
|
||||
### Decomposition
|
||||
|
||||
| Brief # | Title | Files | Depends on | Estimated PR size |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Layout: default user to null + logged-out branch + vitest lock-in | `components/Layout.js`, `test/components/Layout.test.js` (new), `package.json`, `package-lock.json` | none | ~80 LOC source change in Layout; ~60 LOC new test; package.json adds 2 devDeps |
|
||||
| 2 | Pages: pass user explicitly + drop page-level maintainer-email defaults | `pages/scanner.js`, `pages/deck-builder.js`, `pages/deck/[id].js`, `pages/decks.js`, `pages/profile.js`, `pages/settings.js`, `pages/card/[id].js` | 1 | ~30-40 LOC across 7 files |
|
||||
|
||||
Brief 2 depends on Brief 1 because the page changes assume the new logged-out branch exists. Both ship in the same PR. (Decision C = C2.)
|
||||
|
||||
### Slice dependencies (multitask-ready)
|
||||
|
||||
```yaml
|
||||
slice_dependencies:
|
||||
- brief: 1
|
||||
depends_on: []
|
||||
files:
|
||||
- components/Layout.js
|
||||
- test/components/Layout.test.js
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- brief: 2
|
||||
depends_on: [1]
|
||||
files:
|
||||
- pages/scanner.js
|
||||
- pages/deck-builder.js
|
||||
- pages/deck/[id].js
|
||||
- pages/decks.js
|
||||
- pages/profile.js
|
||||
- pages/settings.js
|
||||
- pages/card/[id].js
|
||||
```
|
||||
|
||||
Briefs 1 and 2 do not share any files. Brief 2 depends on Brief 1 only because its acceptance criteria reference the new logged-out branch. The Conductor MUST sequence them (no `/multitask` parallel fan-out for this convoy).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision A — Logged-out rendering branch design (was Q1)
|
||||
|
||||
**A1 — replace user avatar + email + dropdown with a "Sign in" link to `/login`. Hide auth-only nav (already done by existing optional-chains). Public nav items still visible.**
|
||||
|
||||
`NavigationContent` is already null-safe: `authenticatedNavigation` (line 140-142), `myCollectionNavigation` (line 145-157), and `adminNavigation` (line 167) all gate on `user`. The only piece that leaks the maintainer email is `UserProfileDropdown`'s avatar (line 100-104), email/role labels (line 107-110), and dropdown menu items (line 11-18 — Profile/Settings/Admin/Logout linking to authenticated routes). Brief 1 short-circuits `UserProfileDropdown` with a `<Link href="/login">` containing a "Sign in" label and a sign-in icon when `user === null`; the rest of the layout continues to work.
|
||||
|
||||
A2 (elaborate logged-out CTA with marketing copy) is out of scope per the convoy file ("Branding" → `pick-a-name`; "landing-page-rework" not yet queued). A3 (hide layout chrome entirely) breaks anonymous viewing on `pages/invite/{accept,decline}.js`, `pages/cards.js` (`PublicCardsView`), and any other public surface that legitimately renders Layout for non-authenticated users.
|
||||
|
||||
### Decision B — Page audit triage (was Q2)
|
||||
|
||||
Per-page bucket assignment (17 pages):
|
||||
|
||||
| # | Page | Bucket | `useAuth` source | Already passes `user`? | Action |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | `pages/scanner.js` | 1 (Always-auth) | `lib/auth-context` | No (`<Layout>` line 333) | **Fix** — pass `user={user}` |
|
||||
| 2 | `pages/dashboard.js` | 1 | `lib/use-auth` | Yes | None |
|
||||
| 3 | `pages/my-cards.js` | 1 | `lib/use-auth` | Yes | None |
|
||||
| 4 | `pages/profile.js` | 1 | none (uses local `useState` with maintainer-email init) | Yes (passes the leaky useState user) | **Fix** — replace `useState({email:'me@…'})` with `useState(null)` |
|
||||
| 5 | `pages/settings.js` | 1 | none (same pattern as profile) | Yes (leaky) | **Fix** — same as profile |
|
||||
| 6 | `pages/deck-builder.js` | 1 | `lib/auth-context` | No (`<Layout>` ×4) | **Fix** — pass `user={user}` to all four |
|
||||
| 7 | `pages/decks.js` | 1 | `lib/auth-context` | No (`<Layout>` ×3) | **Fix** — pass `user={user}` to all three |
|
||||
| 8 | `pages/admin/card-import.js` | 1 (admin) | none (gets `user` from `AdminProtected` render-prop) | Yes | None |
|
||||
| 9 | `pages/admin/card-editor.js` | 1 (admin) | none (same pattern as card-import) | Yes | None |
|
||||
| 10 | `pages/collections.js` | 1 | `lib/use-auth` | Yes | None |
|
||||
| 11 | `pages/cards.js` | 2 (Public-or-auth) | `lib/use-auth` | Yes (`PublicCardsView` passes `user={null}`; `AuthenticatedCards` passes `user={user}`) | None |
|
||||
| 12 | `pages/card/[id].js` | 2 | none (hardcoded `const user = { email: 'me@…' }`) | Yes (passes the hardcoded const) | **Fix** — `const { user } = useAuth()` from `lib/use-auth` |
|
||||
| 13 | `pages/collection/[identifier].js` | 2 | `lib/use-auth` | Yes | None |
|
||||
| 14 | `pages/community/collections.js` | 2 | `lib/use-auth` | Yes | None |
|
||||
| 15 | `pages/deck/[id].js` | 2 | `lib/auth-context` | No (`<Layout>` ×3) | **Fix** — pass `user={user}` to all three |
|
||||
| 16 | `pages/invite/accept.js` | 3 (Anonymous-allowed) | none | Yes (`user={null}`) | None |
|
||||
| 17 | `pages/invite/decline.js` | 3 | none | Yes (`user={null}`) | None |
|
||||
|
||||
**Net:** 7 pages need code changes (Brief 2). 10 pages are already correct.
|
||||
|
||||
A page-audit grep also surfaced **3 page-level maintainer-email leaks** that the convoy file did not enumerate explicitly: `pages/profile.js`, `pages/settings.js`, and `pages/card/[id].js` initialize their `user` state/const with `email: 'me@randallstillwell.com'`. These are the same bug shape as the Layout default — the convoy's success metric ("No real email address remains in any component default-prop") is satisfied only if these are fixed too. Brief 2 covers them.
|
||||
|
||||
### Decision C — Brief decomposition (was Q3)
|
||||
|
||||
**C2 — two briefs, one PR.**
|
||||
|
||||
Brief 1 ships the Layout change + vitest lock-in (the actual fix). Brief 2 ships the page audit (the cleanup that proves the fix is complete). Brief 2 depends on Brief 1 because its acceptance criteria reference the new logged-out branch.
|
||||
|
||||
C1 (single brief, 12 files) makes the diff harder to review — the Layout change is the conceptually interesting piece; lumping it with 7 mechanical page edits buries it. C3 (fan-out by bucket) creates 4 briefs without parallelization benefit, since Brief 2's pages don't share files with one another but DO all depend on Brief 1, so /multitask gives 4× the orchestration cost for the same wall-clock time.
|
||||
|
||||
### Decision D — vitest coverage (was Q4)
|
||||
|
||||
**D2 — yes, add `test/components/Layout.test.js` with the four assertions enumerated in the test plan.**
|
||||
|
||||
The bug was a default-prop value that nobody caught for ~12 months. A single test that asserts "the rendered tree does not contain `me@randallstillwell.com`" makes the regression impossible to reintroduce silently. Cost: two new devDeps (`jsdom`, `@testing-library/react`), one ~60-LOC test file, per-file `// @vitest-environment jsdom` directive (no global vitest config change). Pattern matches `test/lib/permission-middleware.test.js`'s negative-regression-against-old-shape style (the test for the synthetic-admin shape locks in P0 #1's fix).
|
||||
|
||||
D1 (no tests, manual smoke only) is what the original bug had. Manual smoke is human-attention-bottlenecked and not a regression gate.
|
||||
|
||||
### Anything flagged but not acted on
|
||||
|
||||
- **`MobileNavigation`'s unused `user` prop.** The component accepts `{ user, onMenuOpen }` but never reads `user.*` (the bottom-bar items are static — Cards, Decks, Dashboard, Community, More — none gated on auth state or role). The prop is dead. Removing it is a 2-line cleanup, but the convoy spec lists `MobileNavigation` as out-of-scope-or-fold-in territory and the right call here is to defer: a dead prop is harmless, and removing it touches `Layout.js` (the call site) plus `MobileNavigation.js`, expanding the diff. **Follow-up convoy:** `cleanup-mobile-nav-dead-props` (P3 polish; can fold into `god-component-split` if that lands first).
|
||||
- **Three parallel auth providers.** Brief 2 leaves `pages/scanner.js`, `pages/deck-builder.js`, `pages/deck/[id].js`, `pages/decks.js` importing `useAuth` from `lib/auth-context` (the legacy provider) instead of `lib/use-auth`. The convoy file explicitly defers this to `single-auth-provider`. **Follow-up convoy:** `single-auth-provider` (P1 #9, already queued in `.convoys/ship-readiness.md`).
|
||||
- **Default branding in Layout headers.** Lines 593-596 (mobile drawer header) and 686-689 (desktop sidebar header) render "DH" + "Deck Hearth". The user-prop fix does not touch branding. **Follow-up convoy:** `pick-a-name` (P1 #12, already queued).
|
||||
- **`pages/profile.js` and `pages/settings.js` `loading` state design.** Both pages render `<Layout user={user}>` while `loading === true`. After Brief 2, this means `<Layout user={null}>` during loading → "Sign in" briefly visible to a logged-in user reloading the page. This matches `ProtectedRoute`'s existing loading UX and is acceptable. If product wants to suppress the flash, that's a separate UX convoy (`auth-loading-skeleton`).
|
||||
- **`AGENTS.md` Gotcha #8.** The convoy file says it will be marked RESOLVED in the post-convoy doc-writer pass. Do not edit `AGENTS.md` here.
|
||||
- **`.cursor/rules/ui-and-theming.mdc` § Component conventions.** The rule already documents the intent: "Avoid hardcoded default values for `user` props … default to `null` and render a logged-out state." No rule update needed.
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
---
|
||||
convoy: fix-layout-default-user
|
||||
brief_number: 1
|
||||
depends_on: []
|
||||
files:
|
||||
- components/Layout.js
|
||||
- test/components/Layout.test.js
|
||||
- package.json
|
||||
- package-lock.json
|
||||
cross_brief_commitments:
|
||||
- brief: 2
|
||||
description: |
|
||||
Brief 2 changes the seven pages that today either omit the `user` prop or
|
||||
pass a hardcoded maintainer-email object. Brief 2 assumes Brief 1's new
|
||||
logged-out branch (default `user = null`, "Sign in" CTA in
|
||||
`UserProfileDropdown`) is in place — without it, those pages would render
|
||||
"Sign in" before their own auth state resolved, but Layout's default
|
||||
would silently rewrite that back to the maintainer email. Ship Brief 1
|
||||
first in the diff.
|
||||
---
|
||||
|
||||
# Brief 1: Default Layout's `user` to null + render a logged-out branch + lock the contract with a vitest test
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Change `components/Layout.js`'s `user` default from `{ email: 'me@randallstillwell.com', role: 'user' }` to `null`, replace `UserProfileDropdown`'s avatar+email+menu with a "Sign in" link to `/login` when `user === null`, and add a vitest test under `test/components/Layout.test.js` that locks in the contract by asserting the rendered tree never contains `me@randallstillwell.com` for the no-user / null-user branches.
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
|
||||
- `components/Layout.js` — modified (default-prop fix + logged-out `UserProfileDropdown` branch).
|
||||
- `test/components/Layout.test.js` — new (regression test).
|
||||
- `package.json` — modified (add `jsdom` and `@testing-library/react` to `devDependencies`).
|
||||
- `package-lock.json` — regenerated by `npm install`.
|
||||
|
||||
## Conventions to follow
|
||||
|
||||
- **`.cursor/rules/ui-and-theming.mdc` § Component conventions.** Already documents the intent: "Avoid hardcoded default values for `user` props. … New components must default to `null` and render a logged-out state." This brief is the first concrete application of that rule.
|
||||
- **`.cursor/rules/auth-and-permissions.mdc` § Authentication state on the client.** `useAuth()` returns `{ user, loading, … }` where `user === null` means logged out. Layout's null-user rendering must be safe for that case; do **not** add new logic that throws on `user === null`.
|
||||
- **`.cursor/rules/no-go-zones.mdc`.** Do not edit `components/Layout.js.backup`. Do not edit anything under `lib/**` or `.github/**`. Do not touch `.cursor/rules/**`.
|
||||
- **Brief size discipline.** This brief is one component change, one new test, two devDeps. **Do NOT**:
|
||||
- Split the Layout god-component (`god-component-split` convoy owns that).
|
||||
- Migrate Layout off `lib/auth-context.js` / `lib/admin-auth.js` (Layout doesn't import either today; both legacy providers are queued for `single-auth-provider`).
|
||||
- Rename "Deck Hearth" or "DH" to "TCG Vault" (`pick-a-name` convoy).
|
||||
- Remove the dead `user` prop on `MobileNavigation` (deferred follow-up, see convoy file § "Anything flagged but not acted on").
|
||||
- Touch `components/MobileNavigation.js` at all.
|
||||
- **Vitest test patterns.** Match `test/lib/permission-middleware.test.js`'s shape: `describe` block per behavior cluster, `vi.mock(...)` for module dependencies, plain `expect()` matchers (no jest-dom required). The new test uses a per-file `// @vitest-environment jsdom` directive at the top (vitest v3 supports this) so `vitest.config.js`'s global `environment: 'node'` does not need to change.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
### `components/Layout.js`
|
||||
|
||||
- [ ] **Change the default-prop on line 562.** Before:
|
||||
|
||||
```js
|
||||
export default function Layout({ children, user = { email: 'me@randallstillwell.com', role: 'user' }, showSearch = false }) {
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```js
|
||||
export default function Layout({ children, user = null, showSearch = false }) {
|
||||
```
|
||||
|
||||
No other change to that line.
|
||||
|
||||
- [ ] **Add a logged-out branch to `UserProfileDropdown`.** The branch MUST be placed **after** the existing `useState(false)` call (rules of hooks: hooks must be called in the same order every render — moving the early return above `useState` would throw "Rendered more hooks than during the previous render" the moment `user` flips from `null` to an object on a subsequent render). Verbatim shape — the implementer MAY adjust class names to match neighboring sidebar items, but every prop / behavior must be present:
|
||||
|
||||
```js
|
||||
function UserProfileDropdown({ user, onMobileMenuClose }) {
|
||||
// Hook order is fixed for both branches; do not move this below the
|
||||
// null-user early return — see rules-of-hooks (AGENTS.md Gotcha #11.5).
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
// Logged-out: replace avatar + email + dropdown with a Sign-in CTA.
|
||||
if (!user) {
|
||||
return (
|
||||
<Link href="/login">
|
||||
<div
|
||||
className="w-full flex items-center px-4 py-3 rounded-2xl transition-all duration-200 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 nav-item-hover cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
||||
}}
|
||||
onClick={onMobileMenuClose}
|
||||
>
|
||||
<div className="h-8 w-8 logo-container mr-3 flex items-center justify-center">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium text-sm">Sign in</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Existing dropdown body (profileMenuItems, getProfileIcon, JSX) —
|
||||
// unchanged below this line.
|
||||
}
|
||||
```
|
||||
|
||||
- The `useState` stays where it is today (currently line 9); the early return is inserted **between** `useState` and the existing `profileMenuItems` declaration. `setIsDropdownOpen` is unused on the null branch; that's fine — React does not warn on unused state setters, and the variable is declared because the hook MUST run.
|
||||
- The `<Link href="/login">` is non-negotiable — the test in `test/components/Layout.test.js` queries `getByRole('link', { name: /sign in/i })` and asserts every match has `href="/login"`.
|
||||
- `onClick={onMobileMenuClose}` keeps the mobile drawer behavior consistent with the existing items (the dropdown's existing items also call this on click — see line 73-74 of the current file).
|
||||
- The SVG is a "log-in" / "arrow-into-box" glyph (mirror of the existing logout SVG at line 39-41). Implementer MAY substitute another inline SVG so long as it is wrapped in `aria-hidden="true"` and the visible label is exactly "Sign in" (the test does a case-insensitive `/sign in/i` match — "Sign In" / "Sign in" / "SIGN IN" all work, but "Login" / "Log in" would fail the assertion).
|
||||
- **Lint check.** Run `npm run lint -- components/Layout.js` after the change — confirm zero new `react-hooks/rules-of-hooks` violations. Pre-existing lint baseline issues (per `AGENTS.md` Gotcha #11.5) may be present elsewhere in the file but `react-hooks/rules-of-hooks` should not regress in `UserProfileDropdown`.
|
||||
- [ ] **Do not modify** `NavigationContent` (lines 127-560). Its `authenticatedNavigation`, `myCollectionNavigation`, `adminNavigation` already gate on `user` correctly; the public + community sections render unconditionally and are correct for both logged-out and logged-in states.
|
||||
- [ ] **Do not modify** the Layout body (lines 568-793) — the desktop sidebar, mobile drawer, mobile overlay, and main-content wrapper are all unchanged. They pass `user` (now potentially `null`) to `MobileNavigation`, `NavigationContent`, and `UserProfileDropdown`; each of those handles `null` correctly after this brief.
|
||||
- [ ] **Do not touch** the `'me@randallstillwell.com'` literal anywhere except the line 562 default — there are no other references in `components/Layout.js` (verified by `rg "me@randallstillwell" components/Layout.js` returning a single hit before this brief).
|
||||
- [ ] **Branding.** Lines 593-596 ("DH" / "Deck Hearth" mobile drawer header) and 686-689 (desktop sidebar header) stay unchanged. `pick-a-name` owns branding.
|
||||
|
||||
### `test/components/Layout.test.js` (new)
|
||||
|
||||
- [ ] **Create the directory** `test/components/` if it does not exist (it does not as of this brief). The vitest glob `include: ['test/**/*.test.js']` (see `vitest.config.js` line 8) automatically picks the new file up.
|
||||
- [ ] **Set the per-file environment** with `// @vitest-environment jsdom` as the first line. Do NOT modify `vitest.config.js`'s global `environment: 'node'` — other tests (auth, permission-middleware) run in node and changing the default would force every test through jsdom unnecessarily.
|
||||
- [ ] **Verbatim shape** — the implementer MAY tighten queries, but every assertion must be present and the file must run green:
|
||||
|
||||
```js
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup, screen } from '@testing-library/react';
|
||||
|
||||
// Mock next/router so useRouter() does not crash without a RouterContext.
|
||||
// Layout reads `router.pathname` only; the rest of the surface (`prefetch`,
|
||||
// `events`, `push`) is for next/link's internals — provide stubs so prefetch
|
||||
// does not throw when <Link> mounts.
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
pathname: '/',
|
||||
asPath: '/',
|
||||
query: {},
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn().mockResolvedValue(undefined),
|
||||
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock next/link to a plain <a>. The real next/link triggers prefetch on
|
||||
// mount via the router; bypassing it removes a class of jsdom flake without
|
||||
// changing the rendered DOM that the assertions inspect.
|
||||
vi.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children, ...rest }) => {
|
||||
// children may be a single element (e.g. a <div>) or text; wrap in <a>.
|
||||
return (
|
||||
<a href={typeof href === 'string' ? href : ''} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the theme context so useTheme() does not require a ThemeProvider.
|
||||
vi.mock('../../lib/theme-context', () => ({
|
||||
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
import Layout from '../../components/Layout';
|
||||
|
||||
describe('Layout — logged-out rendering (regression: P0 #7)', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('does NOT render the maintainer email when no user prop is passed', () => {
|
||||
const { container } = render(<Layout>page body</Layout>);
|
||||
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
||||
});
|
||||
|
||||
it('does NOT render the maintainer email when user is null', () => {
|
||||
const { container } = render(<Layout user={null}>page body</Layout>);
|
||||
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
||||
});
|
||||
|
||||
it('renders a Sign-in link to /login when user is null', () => {
|
||||
render(<Layout user={null}>page body</Layout>);
|
||||
const links = screen.getAllByRole('link', { name: /sign in/i });
|
||||
expect(links.length).toBeGreaterThanOrEqual(1);
|
||||
// Both desktop sidebar + mobile drawer render UserProfileDropdown,
|
||||
// so we expect TWO Sign-in links (one per copy).
|
||||
for (const link of links) {
|
||||
expect(link.getAttribute('href')).toBe('/login');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the supplied user email when user is an object', () => {
|
||||
const { container } = render(
|
||||
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
||||
);
|
||||
expect(container.textContent).toContain('foo@bar.com');
|
||||
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
||||
});
|
||||
|
||||
it('does NOT render a "Guest" placeholder when logged out', () => {
|
||||
// Decision A says the logged-out copy is "Sign in", not "Guest".
|
||||
// This test prevents a future revert that ships "Guest" as the default
|
||||
// (which would still hide the maintainer email but skip the CTA).
|
||||
const { container } = render(<Layout user={null}>page body</Layout>);
|
||||
expect(container.textContent).not.toContain('Guest');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **All five tests pass** under `npm run test:run`.
|
||||
- [ ] **The pre-existing 24 tests** (16 in `permission-middleware.test.js`, 3 in `auth-secret.test.js`, 5 in `auth-utils.test.js`) **remain green**. This brief does not touch any file they cover, so the only failure mode is a CI environment regression — investigate and fix before merging.
|
||||
|
||||
### `package.json` + `package-lock.json`
|
||||
|
||||
- [ ] **Add to `devDependencies`** — single command: `npm install --save-dev jsdom @testing-library/react`. Resolved versions at architect time (2026-05-23): `jsdom@29.1.1`, `@testing-library/react@16.3.2`. Caret ranges are fine (matches the existing devDep style — `vitest@^3.2.4`).
|
||||
- [ ] **Verify `@testing-library/dom` is present.** `@testing-library/react@16` declares `@testing-library/dom@^10.0.0` as a **peer dependency** (verified by `npm view @testing-library/react peerDependencies`). npm 7+ auto-installs peers, so a single `npm install --save-dev jsdom @testing-library/react` should resolve it transitively. After running install, run `npm ls @testing-library/dom` and confirm a single `^10.x` entry appears. If it does NOT (npm version too old or peer-install was disabled), explicitly add it: `npm install --save-dev @testing-library/dom@^10.0.0`.
|
||||
- [ ] **Do NOT add `@testing-library/jest-dom`.** The test uses plain `expect().toContain()` / `toBeGreaterThanOrEqual()` matchers — no jest-dom matchers needed. Adding it would expand the dep surface for no acceptance-criteria benefit.
|
||||
- [ ] **Do NOT add `@types/react`.** The repo is JavaScript-only (`AGENTS.md` Gotcha #9); the React 18/19 type peer on `@testing-library/react` is irrelevant when there is no `tsconfig.json`. npm will warn-and-skip the type peers, which is the documented behavior — leave the warning alone.
|
||||
- [ ] **Do NOT bump any other dep** while you have `package.json` open. No `next` bump, no `vitest` bump, no react bump. If npm hoists a transitive minor that flips a lockfile entry, that's fine; if it tries to bump a top-level dep, stop and ask.
|
||||
- [ ] **Verify** `package-lock.json` was regenerated and committed (the `npm install` run produces the lockfile diff; commit it as part of the same change).
|
||||
|
||||
### `vitest.config.js`
|
||||
|
||||
- [ ] **No change.** The new test file uses `// @vitest-environment jsdom` per-file. The global `environment: 'node'` (line 5) stays so the existing 24 tests do not slow down by going through jsdom unnecessarily.
|
||||
- [ ] (Sanity check, not a code change.) Confirm the existing `include: ['test/**/*.test.js']` glob (line 8) catches `test/components/Layout.test.js`. It does — the test file matches the recursive glob.
|
||||
|
||||
### Smoke (manual, in addition to the vitest run)
|
||||
|
||||
Run these in order; paste the relevant output / screenshots into the PR description:
|
||||
|
||||
- [ ] **Logged-out smoke.** With no `auth_token` in `localStorage` (DevTools → Application → Local Storage → clear `auth_token`), `npm run dev` and visit `http://localhost:3000/dashboard` (or any page that renders Layout). Expect:
|
||||
- The desktop sidebar bottom shows "Sign in" with a small icon, in place of the avatar + email + dropdown chevron.
|
||||
- Clicking "Sign in" routes to `/login`.
|
||||
- **The string `me@randallstillwell.com` does NOT appear anywhere on the page.** (Open DevTools → Console → run `document.body.innerText.includes('me@randallstillwell.com')` → expect `false`.)
|
||||
- The "My Collection", admin, and authenticated-only nav items are hidden.
|
||||
- Public + community nav items (Cards, Scanner, Deck Builder placeholder, Community section) are still visible.
|
||||
- [ ] **Logged-in smoke.** Log in as a non-admin (`admin@tcgvault.com` works since the seed admin has `role='admin'` — use a non-admin signup or temporarily flip the row). Visit `/dashboard`. Expect:
|
||||
- The desktop sidebar bottom shows the real user's email + role.
|
||||
- Clicking the avatar opens the dropdown with Profile / Settings / Logout (no Admin Panel for non-admin).
|
||||
- [ ] **Mobile drawer smoke.** Resize Chrome DevTools to a phone preset (iPhone 13). Open the mobile drawer ("More" tap). Expect: the same Sign-in / authenticated states render in the drawer's bottom section as in the desktop sidebar.
|
||||
- [ ] **Lint.** `npm run lint` exits 0 (or matches the existing pre-PR baseline — pre-existing lint errors are fine; do not introduce new ones).
|
||||
- [ ] **Tests.** `npm run test:run` is green: 5 new + 24 pre-existing = 29 tests pass.
|
||||
|
||||
### Out of scope (do not do these)
|
||||
|
||||
- [ ] No edits to any of the 17 pages — that's Brief 2.
|
||||
- [ ] No edits to `components/MobileNavigation.js` — its dead `user` prop is a deferred cleanup.
|
||||
- [ ] No edits to `lib/use-auth.js`, `lib/auth-context.js`, `lib/admin-auth.js`, `lib/permission-middleware.js`, or `lib/auth-secret.js`. The auth surface is downstream of this brief.
|
||||
- [ ] No edits to `vitest.config.js` (the per-file `// @vitest-environment jsdom` directive is the entire mechanism).
|
||||
- [ ] No new vitest tests beyond `test/components/Layout.test.js`. Brief 2 does not add tests either (Decision D = D2 covers the contract at the component boundary).
|
||||
- [ ] No `.cursor/rules/*.mdc` updates. The rule (`ui-and-theming.mdc` § Component conventions) already documents the intent.
|
||||
- [ ] No `AGENTS.md` Gotcha #8 update — that's the post-convoy doc-writer pass.
|
||||
- [ ] No `README.md` update.
|
||||
- [ ] No `CHANGELOG.md` (none exists yet — `adopt-keep-a-changelog` is a separate convoy).
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
The Layout default is the actual P0 #7 bug; this brief fixes it at the source and locks in the contract with a regression test that asserts the maintainer email never reappears for the null-user branches. Splitting the page audit into Brief 2 keeps the conceptually interesting change (Layout + logged-out branch + test harness) reviewable on its own; the page edits are mechanical and benefit from being grouped separately. Adding `jsdom` + `@testing-library/react` as devDeps is the smallest harness that lets vitest exercise React rendering — the tools are common, the surface is two packages, and the value (preventing a recurrence of a default-prop email leak) is high.
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
---
|
||||
convoy: fix-layout-default-user
|
||||
brief_number: 2
|
||||
depends_on: [1]
|
||||
files:
|
||||
- pages/scanner.js
|
||||
- pages/deck-builder.js
|
||||
- pages/deck/[id].js
|
||||
- pages/decks.js
|
||||
- pages/profile.js
|
||||
- pages/settings.js
|
||||
- pages/card/[id].js
|
||||
cross_brief_commitments:
|
||||
- brief: 1
|
||||
description: |
|
||||
Brief 1 changed `components/Layout.js` so the `user` prop defaults to
|
||||
`null` and a logged-out branch in `UserProfileDropdown` renders a
|
||||
"Sign in" CTA. This brief assumes that branch exists — the seven pages
|
||||
below either (a) currently rely on the maintainer-email default by
|
||||
omitting `user`, or (b) seed `user` from a hardcoded
|
||||
`me@randallstillwell.com` initializer. After this brief, every Layout
|
||||
call site sources `user` from `useAuth()` (or remains a legitimate
|
||||
`user={null}` for anonymous-allowed surfaces). Without Brief 1's
|
||||
logged-out branch, this brief would silently re-leak the maintainer
|
||||
email through Layout's default. Ship Brief 1 first in the diff.
|
||||
---
|
||||
|
||||
# Brief 2: Pages — pass `user` explicitly to Layout, and drop page-level maintainer-email defaults
|
||||
|
||||
## Goal (1 sentence)
|
||||
|
||||
Audit the 17 pages that render `Layout` and fix the seven that today either omit the `user` prop (so Layout's default kicks in) or seed `user` from a hardcoded `email: 'me@randallstillwell.com'` initializer; after this brief, every page sources `user` from an auth hook (or passes `user={null}` deliberately on anonymous-allowed surfaces).
|
||||
|
||||
## Files in scope (do not edit anything else)
|
||||
|
||||
- `pages/scanner.js` — modified.
|
||||
- `pages/deck-builder.js` — modified.
|
||||
- `pages/deck/[id].js` — modified.
|
||||
- `pages/decks.js` — modified.
|
||||
- `pages/profile.js` — modified.
|
||||
- `pages/settings.js` — modified.
|
||||
- `pages/card/[id].js` — modified.
|
||||
|
||||
## Conventions to follow
|
||||
|
||||
- **`.cursor/rules/auth-and-permissions.mdc` § Canonical surface vs Legacy.** The canonical client hook is `lib/use-auth.js::useAuth`. The legacy `lib/auth-context.js::useAuth` and `lib/admin-auth.js::useAdmin` are still wired for compatibility but should not gain new consumers. **However, four of the seven files in this brief already import `useAuth` from `lib/auth-context.js`.** Do NOT migrate those imports to `lib/use-auth.js` here — that's the queued `single-auth-provider` convoy's job. The migration touches ~30 files and needs a coordinated sweep; cherry-picking four of them would diverge that convoy's plan. **Specifically:**
|
||||
- `pages/scanner.js` — keep `import { useAuth } from '../lib/auth-context';`.
|
||||
- `pages/deck-builder.js` — keep `import { useAuth } from '../lib/auth-context';`.
|
||||
- `pages/deck/[id].js` — keep `import { useAuth } from '../../lib/auth-context';`.
|
||||
- `pages/decks.js` — keep `import { useAuth } from '../lib/auth-context';`.
|
||||
- **`.cursor/rules/ui-and-theming.mdc` § Component conventions.** Pages that render Layout MUST pass `user` explicitly. Page-level `useState({ email: 'me@…' })` initializers fail the same rule even though Layout itself is fixed; clear them.
|
||||
- **`.cursor/rules/no-go-zones.mdc`.** Do not edit `components/Layout.js.backup`.
|
||||
- **Brief size discipline.** This brief is seven page-level edits, each small. **Do NOT**:
|
||||
- Touch any file outside `files:` above. Specifically: no `components/**`, no `lib/**`, no `test/**`, no `package.json`, no `.github/**`, no `.cursor/rules/**`.
|
||||
- Add new vitest tests. Brief 1's `test/components/Layout.test.js` covers the contract at the component boundary; per-page tests would duplicate it.
|
||||
- Migrate a page off `lib/auth-context.js` (see above).
|
||||
- Restructure `loading` / `useEffect` chains. Each page already has a working data-fetch pattern; the only thing changing is where the seed `user` comes from.
|
||||
- Convert pages to TypeScript (the repo is JavaScript-only — `AGENTS.md` Gotcha #9).
|
||||
- **No new dependencies.** All seven files use modules already imported elsewhere in the codebase.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The seven files split into **two patterns**. Apply the corresponding pattern to each.
|
||||
|
||||
### Pattern A — pages that omit `user` on Layout (4 files)
|
||||
|
||||
These pages already call `useAuth()` (from `lib/auth-context.js`) but render `<Layout>` without forwarding the `user` reference. Add `user={user}` to every `<Layout>` call.
|
||||
|
||||
#### `pages/scanner.js`
|
||||
|
||||
- [ ] Locate line 333: `<Layout>`.
|
||||
- [ ] Replace with `<Layout user={user}>`.
|
||||
- [ ] No other change to the file. The existing `import { useAuth } from '../lib/auth-context';` (line 8) and `const { user } = useAuth();` (line 11) stay as-is.
|
||||
- [ ] **Verify the `if (!user) router.push('/login')` redirect** (line 30-35) still runs first. After Brief 1, Layout will render the logged-out shape briefly while `user === null` and `loading === true`; once the redirect to `/login` fires, the user lands on the AuthLayout-rendered login page. Acceptable.
|
||||
|
||||
#### `pages/deck-builder.js`
|
||||
|
||||
- [ ] Replace each of the four `<Layout>` calls with `<Layout user={user}>`. Lines (per current grep): 262, 277, 287, 303.
|
||||
- [ ] No other change. `import { useAuth } from '../lib/auth-context';` (line 7) and `const { user } = useAuth();` (line 11) stay.
|
||||
|
||||
#### `pages/deck/[id].js`
|
||||
|
||||
- [ ] Replace each of the three `<Layout>` calls with `<Layout user={user}>`. Lines: 132, 142, 160.
|
||||
- [ ] No other change. `import { useAuth } from '../../lib/auth-context';` (line 6) and `const { user } = useAuth();` (line 10) stay.
|
||||
|
||||
#### `pages/decks.js`
|
||||
|
||||
- [ ] Replace each of the three `<Layout>` calls with `<Layout user={user}>`. Lines: 161, 176, 185.
|
||||
- [ ] No other change. `import { useAuth } from '../lib/auth-context';` (line 5) and `const { user } = useAuth();` (line 8) stay.
|
||||
|
||||
### Pattern B — pages with hardcoded maintainer-email initializers (3 files)
|
||||
|
||||
These pages seed their `user` state/const with the maintainer's email at the page level. Replace the initializer with `null` (and source from `useAuth()` where the page does not already have an auth hook). Add the necessary null-guards on sync reads.
|
||||
|
||||
#### `pages/profile.js`
|
||||
|
||||
- [ ] **Replace lines 10-20** (`const [user, setUser] = useState({ email: 'me@randallstillwell.com', role: 'admin', first_name: '', last_name: '', username: '', bio: '', avatar_url: '', favorite_games: ['MTG'], created_at: new Date().toISOString() });`) with:
|
||||
|
||||
```js
|
||||
const [user, setUser] = useState(null);
|
||||
```
|
||||
|
||||
- [ ] **Add null-guards on every sync read of `user.*`** in the JSX. The reader functions (`getDisplayName`, `getInitials` near lines 239-249) already use optional chaining where it matters (`user.first_name`, `user.last_name`, `user.username`, `user.email`); confirm they handle `null`:
|
||||
- `getDisplayName()`: returns `user.first_name || user.last_name ...` — change to start with `if (!user) return '';` so the first sync render is safe.
|
||||
- `getInitials()`: same pattern — `if (!user) return '';` at the top.
|
||||
- In the JSX (around line 260-450), every `user.email`, `user.role`, `user.username`, `user.bio`, `user.avatar_url`, `user.first_name`, `user.last_name`, `user.created_at` access must use `user?.*` (optional chaining). Most are inside `{user && (...)}` blocks already; verify each one.
|
||||
- [ ] **The `loading` branch** (line 258-267, `if (loading) { return <Layout user={user}>... }`) renders `<Layout user={user}>` — after this brief, `user` is `null` during loading, so Layout will show the "Sign in" branch. That's correct; `loadUserProfile` redirects to `/login` if no token (line 51-54), so this flash only happens for already-authenticated users while their profile fetches.
|
||||
- [ ] **Verify with `rg "user\." pages/profile.js`** — every match must be inside an optional-chain (`user?.`) or a `{user && ...}` guard or `if (!user)` early-return.
|
||||
- [ ] **Do NOT** add an `import { useAuth } from '...'`. The page's existing pattern (manual fetch from `/api/user/profile`) stays — adding `useAuth` here would be a second source of truth and a `single-auth-provider` migration. The point of this brief is that the seed value is `null`, not who provides it.
|
||||
|
||||
#### `pages/settings.js`
|
||||
|
||||
- [ ] **Replace lines 9-12** (`const [user, setUser] = useState({ email: 'me@randallstillwell.com', role: 'admin' });`) with:
|
||||
|
||||
```js
|
||||
const [user, setUser] = useState(null);
|
||||
```
|
||||
|
||||
- [ ] **Audit every `user.*` read in the JSX** (notably line 306 `value={user.email}` in the email read-only field). Wrap each in optional chaining or a `{user && (...)}` guard. The simplest fix for line 306 is `value={user?.email || ''}`.
|
||||
- [ ] **The `loading` branch** (line 228-236, `if (loading) { return <Layout user={user}>... }`) renders `<Layout user={user}>` — same flow as `profile.js`. After this brief, the loading flash shows "Sign in" briefly until `loadSettings` redirects (line 53-55) or resolves with the real user.
|
||||
- [ ] **Do NOT** add `useAuth`. Same reasoning as `profile.js`.
|
||||
- [ ] **Verify with `rg "user\." pages/settings.js`** — every match must be optional-chain / guard / early-return safe.
|
||||
|
||||
#### `pages/card/[id].js`
|
||||
|
||||
- [ ] **Replace lines 13-16** (the hardcoded `const user = { email: 'me@randallstillwell.com', role: 'user' };`) with:
|
||||
|
||||
```js
|
||||
const { user } = useAuth();
|
||||
```
|
||||
|
||||
- [ ] **Add the import at line 4** (between the existing `useIsAdmin` import on line 4 and the next line — alphabetical / grouping is the implementer's call):
|
||||
|
||||
```js
|
||||
import { useAuth } from '../../lib/use-auth';
|
||||
```
|
||||
|
||||
- **`lib/use-auth.js`** is the canonical hook (`auth-and-permissions.mdc` § Canonical surface). `card/[id].js` does not currently import any auth hook (the `useIsAdmin` import is from `lib/admin-auth.js`, but `useIsAdmin` returns a boolean, not a user object), so we are adding a fresh consumer rather than migrating one — the "no new consumers of legacy providers" rule is satisfied.
|
||||
- [ ] **Audit every `user.*` read in the file** — `pages/card/[id].js` is 913 lines; run `rg "user\." pages/card/[id].js` and confirm every match is `user?.*` or under a `{user && (...)}` guard. Likely matches: `user.userId`, `user.email`, `user.role`. The page is public-or-authenticated, so each `user.*` site should already gracefully handle "no user" — the convoy's bucket assignment for this page is **Bucket 2 (public-or-auth)**.
|
||||
- [ ] **The three `<Layout user={user}>` calls** (lines 343, 353, 376) stay — `user` is now sourced from `useAuth()` and may be `null` for anonymous viewers.
|
||||
- [ ] **Verify** the page still renders for an anonymous visitor at `/card/some-id` after the change. (Smoke test below.)
|
||||
|
||||
### Repo-wide grep verification (run before opening PR)
|
||||
|
||||
- [ ] `rg "me@randallstillwell" --type js` returns hits ONLY in `components/Layout.js.backup` (no-go zone, untouched). Specifically: NO hits in `components/Layout.js`, `pages/profile.js`, `pages/settings.js`, `pages/card/[id].js`. (The existing `components/Layout.js` hit was removed by Brief 1.)
|
||||
- [ ] `rg "<Layout>" pages --type js` returns ZERO hits — every `Layout` opening tag in `pages/` includes a `user=` prop.
|
||||
- [ ] `rg "import.*useAuth.*lib/auth-context" pages --type js` returns the SAME four hits as before this brief (`pages/scanner.js`, `pages/deck-builder.js`, `pages/deck/[id].js`, `pages/decks.js`). No new hits, no removals — confirms we didn't migrate the legacy-provider consumers.
|
||||
- [ ] `rg "import.*useAuth.*lib/use-auth" pages --type js` includes `pages/card/[id].js` (new hit) plus the existing six (`pages/dashboard.js`, `pages/my-cards.js`, `pages/cards.js`, `pages/collection/[identifier].js`, `pages/community/collections.js`, `pages/collections.js`).
|
||||
|
||||
### Smoke (manual)
|
||||
|
||||
Run these in order; paste the relevant output / screenshots into the PR description. Each scenario MUST be tested both **logged out** (no `auth_token` in `localStorage`) and **logged in** (a real user account).
|
||||
|
||||
- [ ] **`/scanner`**
|
||||
- Logged out: redirected to `/login`. Layout never renders maintainer email (the redirect fires from `useEffect` but Layout briefly shows "Sign in" before navigation). DevTools console: no errors.
|
||||
- Logged in: navbar shows real user email; scanner UI loads.
|
||||
- [ ] **`/deck-builder`** (no query string, just the bare route)
|
||||
- Logged out: Layout shows "Sign in" in the sidebar; the page's own loading / empty state renders without crashing.
|
||||
- Logged in: navbar shows real user email; deck builder loads.
|
||||
- [ ] **`/deck/<some-id>`** (use any deck id from `npm run dev` admin → decks list)
|
||||
- Logged out (public deck): Layout shows "Sign in"; the deck detail still renders (this is a Bucket 2 page).
|
||||
- Logged in: navbar shows real user email; deck detail loads.
|
||||
- [ ] **`/decks`**
|
||||
- Logged out: Layout shows "Sign in"; the page's own auth-gated content (private deck list) shows the appropriate empty / login-prompt state.
|
||||
- Logged in: navbar shows real user email; deck list loads.
|
||||
- [ ] **`/profile`**
|
||||
- Logged out: redirected to `/login` by `loadUserProfile`'s 401 branch. Layout briefly shows "Sign in" before redirect. **No `me@randallstillwell.com` flash anywhere** — this is the change you are smoke-testing.
|
||||
- Logged in: navbar shows real user email; profile loads with real user data.
|
||||
- [ ] **`/settings`**
|
||||
- Logged out: redirected to `/login`. Same flash as profile.
|
||||
- Logged in: navbar shows real user email; settings load.
|
||||
- [ ] **`/card/<some-id>`** (use any card id from `/cards`)
|
||||
- Logged out: Layout shows "Sign in"; card detail still renders (this is the public-card-detail page, Bucket 2). **No `me@randallstillwell.com` anywhere on the page.** Open DevTools → Console → `document.body.innerText.includes('me@randallstillwell.com')` → expect `false`.
|
||||
- Logged in: navbar shows real user email; card detail loads, "Add to collection" UI works.
|
||||
- [ ] **Lint.** `npm run lint` exits 0 (or matches the existing pre-PR baseline; do not introduce new lint errors).
|
||||
- [ ] **Tests.** `npm run test:run` is green: 5 (Brief 1) + 24 (pre-existing) = 29 tests pass.
|
||||
|
||||
### Out of scope (do not do these)
|
||||
|
||||
- [ ] No edits to any of the other 10 pages (`pages/dashboard.js`, `pages/my-cards.js`, `pages/cards.js`, `pages/collection/[identifier].js`, `pages/community/collections.js`, `pages/collections.js`, `pages/invite/accept.js`, `pages/invite/decline.js`, `pages/admin/card-import.js`, `pages/admin/card-editor.js`). They already pass `user` correctly per the per-bucket audit in `.convoys/fix-layout-default-user.md` § Decisions B.
|
||||
- [ ] No edits to `components/**`, `lib/**`, `test/**`, `package.json`, `package-lock.json`, `.github/**`, `.cursor/rules/**`.
|
||||
- [ ] No migration of the four legacy-provider importers (`scanner.js`, `deck-builder.js`, `deck/[id].js`, `decks.js`) to `lib/use-auth.js`. That's `single-auth-provider`.
|
||||
- [ ] No removal of the dead `user` prop on `MobileNavigation` — flagged for follow-up.
|
||||
- [ ] No vitest tests added — Brief 1's `test/components/Layout.test.js` is the convoy's single test addition.
|
||||
- [ ] No `AGENTS.md` Gotcha #8 update — post-convoy doc-writer pass.
|
||||
- [ ] No README / CHANGELOG / TESTING_GUIDE updates.
|
||||
|
||||
## Rationale (≤3 sentences)
|
||||
|
||||
The convoy's success metric requires that no real email address remains in any component default-prop AND that every Layout call site passes `user` correctly; Brief 1 fixed Layout but four pages omit the prop entirely (re-triggering the default if not for Brief 1's null default) and three pages seed page-level state with the maintainer email (a parallel anti-pattern that Brief 1 cannot reach). Splitting these seven mechanical edits into Brief 2 keeps Brief 1's diff focused on the actual fix + test, while this brief sweeps the call sites in one pass. Leaving the four legacy-`auth-context` imports alone preserves the planned `single-auth-provider` migration's clean diff — fixing the user-prop default does not require re-architecting which auth hook a page imports.
|
||||
|
|
@ -6,8 +6,35 @@ import MobileNavigation from './MobileNavigation';
|
|||
|
||||
// User Profile Dropdown Component
|
||||
function UserProfileDropdown({ user, onMobileMenuClose }) {
|
||||
// Hook order is fixed for both branches; do not move this below the
|
||||
// null-user early return — see rules-of-hooks (AGENTS.md Gotcha #11.5).
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
// Logged-out: replace avatar + email + dropdown with a Sign-in CTA.
|
||||
if (!user) {
|
||||
return (
|
||||
<Link href="/login">
|
||||
<div
|
||||
className="w-full flex items-center px-4 py-3 rounded-2xl transition-all duration-200 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 nav-item-hover cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
'--tw-ring-color': 'var(--accent-ember)',
|
||||
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
||||
}}
|
||||
onClick={onMobileMenuClose}
|
||||
>
|
||||
<div className="h-8 w-8 logo-container mr-3 flex items-center justify-center">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium text-sm">Sign in</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const profileMenuItems = [
|
||||
{ name: 'Profile', href: '/profile', icon: 'user' },
|
||||
{ name: 'Settings', href: '/settings', icon: 'settings' },
|
||||
|
|
@ -559,7 +586,7 @@ function NavigationContent({ user, router, onItemClick }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children, user = { email: 'me@randallstillwell.com', role: 'user' }, showSearch = false }) {
|
||||
export default function Layout({ children, user = null, showSearch = false }) {
|
||||
const router = useRouter();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
|
|
|||
662
package-lock.json
generated
662
package-lock.json
generated
|
|
@ -23,9 +23,12 @@
|
|||
"resend": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
@ -45,6 +48,57 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
|
|
@ -280,6 +334,16 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
|
|
@ -328,6 +392,159 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
||||
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
|
||||
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
|
||||
|
|
@ -960,6 +1177,24 @@
|
|||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||
|
|
@ -2233,6 +2468,54 @@
|
|||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
|
||||
|
|
@ -2244,6 +2527,13 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
@ -3194,6 +3484,16 @@
|
|||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/array-buffer-byte-length": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
|
||||
|
|
@ -3492,6 +3792,16 @@
|
|||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
|
@ -3828,6 +4138,20 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
|
@ -3857,6 +4181,20 @@
|
|||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
|
|
@ -3929,6 +4267,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
|
|
@ -3991,6 +4336,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
|
@ -4015,6 +4370,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
|
|
@ -5476,6 +5838,19 @@
|
|||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
@ -5871,6 +6246,13 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
|
|
@ -6093,6 +6475,67 @@
|
|||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
|
||||
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
|
@ -6363,6 +6806,16 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -6383,6 +6836,13 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
|
@ -6883,6 +7343,32 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||
|
|
@ -7275,6 +7761,34 @@
|
|||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -7350,6 +7864,13 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-promise-suspense": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
|
||||
|
|
@ -7432,6 +7953,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-4.7.0.tgz",
|
||||
|
|
@ -7656,6 +8187,19 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
|
|
@ -8271,6 +8815,13 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
|
|
@ -8448,6 +8999,26 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.1.1.tgz",
|
||||
"integrity": "sha512-VuvOq9QVVdzQyIwynB0MRZlEup+u5BD62FjgmKvRDFO8u1RgAzpeg7Qd70hUmrxwkkecqoz1N6t1yGMygx7rnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.1.1.tgz",
|
||||
"integrity": "sha512-v9zYcyFEAJBeyG7g4+y/HFL9i2cHqpV+9cHohNZIhA6xjO2MSVgijFgx6quQaRBDzM5FT8fs5NPjsNITOhlCzg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -8461,6 +9032,32 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
|
|
@ -8952,6 +9549,19 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
|
|
@ -8961,6 +9571,41 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -9215,6 +9860,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -30,9 +30,12 @@
|
|||
"resend": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||
import { useRouter } from 'next/router';
|
||||
import Layout from '../../components/Layout';
|
||||
import { useIsAdmin } from '../../lib/admin-auth';
|
||||
import { useAuth } from '../../lib/use-auth';
|
||||
import CollectionSelectionModal from '../../components/CollectionSelectionModal';
|
||||
import { ManaCost, ColorIdentity, AdvancedManaCost } from '../../components/ManaSymbols';
|
||||
import ManaSymbolSettings from '../../components/ManaSymbolSettings';
|
||||
|
|
@ -9,11 +10,7 @@ import ManaSymbolSettings from '../../components/ManaSymbolSettings';
|
|||
export default function CardDetail() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
|
||||
const user = {
|
||||
email: 'me@randallstillwell.com',
|
||||
role: 'user'
|
||||
};
|
||||
const { user } = useAuth();
|
||||
|
||||
const [card, setCard] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export default function DeckBuilder() {
|
|||
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Please log in to use the deck builder</h1>
|
||||
|
|
@ -274,7 +274,7 @@ export default function DeckBuilder() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
|
||||
</div>
|
||||
|
|
@ -284,7 +284,7 @@ export default function DeckBuilder() {
|
|||
|
||||
if (!deck) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Deck not found</h1>
|
||||
|
|
@ -300,7 +300,7 @@ export default function DeckBuilder() {
|
|||
const stats = getDeckStats();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export default function DeckDetail() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
|
||||
</div>
|
||||
|
|
@ -139,7 +139,7 @@ export default function DeckDetail() {
|
|||
|
||||
if (!deck) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Deck not found</h1>
|
||||
|
|
@ -157,7 +157,7 @@ export default function DeckDetail() {
|
|||
const isOwner = user && deck.user_id === user.userId;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export default function Decks() {
|
|||
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Please log in to view your decks</h1>
|
||||
|
|
@ -173,7 +173,7 @@ export default function Decks() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
|
||||
</div>
|
||||
|
|
@ -182,7 +182,7 @@ export default function Decks() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
|
|
|
|||
|
|
@ -7,17 +7,7 @@ export default function Profile() {
|
|||
const fileInputRef = useRef(null);
|
||||
|
||||
// User state
|
||||
const [user, setUser] = useState({
|
||||
email: 'me@randallstillwell.com',
|
||||
role: 'admin',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
username: '',
|
||||
bio: '',
|
||||
avatar_url: '',
|
||||
favorite_games: ['MTG'],
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// UI state
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -236,6 +226,7 @@ export default function Profile() {
|
|||
};
|
||||
|
||||
const getDisplayName = () => {
|
||||
if (!user) return '';
|
||||
if (user.first_name || user.last_name) {
|
||||
return `${user.first_name} ${user.last_name}`.trim();
|
||||
}
|
||||
|
|
@ -243,6 +234,7 @@ export default function Profile() {
|
|||
};
|
||||
|
||||
const getInitials = () => {
|
||||
if (!user) return '';
|
||||
if (user.first_name || user.last_name) {
|
||||
return `${user.first_name?.charAt(0) || ''}${user.last_name?.charAt(0) || ''}`.toUpperCase();
|
||||
}
|
||||
|
|
@ -296,7 +288,7 @@ export default function Profile() {
|
|||
{/* Avatar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative inline-block">
|
||||
{user.avatar_url ? (
|
||||
{user?.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt="Profile"
|
||||
|
|
@ -358,29 +350,29 @@ export default function Profile() {
|
|||
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{getDisplayName()}
|
||||
</h2>
|
||||
{user.username && (
|
||||
{user?.username && (
|
||||
<p className="text-lg mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
@{user.username}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.email}
|
||||
{user?.email}
|
||||
</p>
|
||||
<div className="flex items-center justify-center mt-2">
|
||||
<span
|
||||
className="px-3 py-1 text-xs rounded-full font-medium"
|
||||
style={{
|
||||
backgroundColor: user.role === 'admin' ? 'var(--accent-ember)' : 'var(--accent-gold)',
|
||||
backgroundColor: user?.role === 'admin' ? 'var(--accent-ember)' : 'var(--accent-gold)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{user.role?.toUpperCase()}
|
||||
{user?.role?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
{user.bio && (
|
||||
{user?.bio && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.bio}
|
||||
|
|
@ -389,11 +381,13 @@ export default function Profile() {
|
|||
)}
|
||||
|
||||
{/* Member Since */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Member since {formatDate(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{user?.created_at && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Member since {formatDate(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
|
|
@ -468,7 +462,7 @@ export default function Profile() {
|
|||
/>
|
||||
) : (
|
||||
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.first_name || 'Not set'}
|
||||
{user?.first_name || 'Not set'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -486,7 +480,7 @@ export default function Profile() {
|
|||
/>
|
||||
) : (
|
||||
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.last_name || 'Not set'}
|
||||
{user?.last_name || 'Not set'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -507,7 +501,7 @@ export default function Profile() {
|
|||
/>
|
||||
) : (
|
||||
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.username || 'Not set'}
|
||||
{user?.username || 'Not set'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -527,7 +521,7 @@ export default function Profile() {
|
|||
/>
|
||||
) : (
|
||||
<p className="py-2 text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.bio || 'No bio set'}
|
||||
{user?.bio || 'No bio set'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -546,19 +540,19 @@ export default function Profile() {
|
|||
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 ${
|
||||
editMode ? 'cursor-pointer hover:shadow-md' : 'cursor-default'
|
||||
} ${
|
||||
(editMode ? formData.favorite_games : user.favorite_games)?.includes(game.value)
|
||||
(editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
|
||||
? 'shadow-lg'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: (editMode ? formData.favorite_games : user.favorite_games)?.includes(game.value)
|
||||
backgroundColor: (editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
|
||||
? 'var(--accent-ember)'
|
||||
: 'var(--bg-tertiary)',
|
||||
color: (editMode ? formData.favorite_games : user.favorite_games)?.includes(game.value)
|
||||
color: (editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
|
||||
? 'white'
|
||||
: 'var(--text-primary)',
|
||||
border: `1px solid ${
|
||||
(editMode ? formData.favorite_games : user.favorite_games)?.includes(game.value)
|
||||
(editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
|
||||
? 'var(--accent-ember)'
|
||||
: 'var(--border)'
|
||||
}`
|
||||
|
|
@ -578,7 +572,7 @@ export default function Profile() {
|
|||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user.email}
|
||||
{user?.email}
|
||||
</p>
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-200">
|
||||
Verified
|
||||
|
|
@ -596,11 +590,11 @@ export default function Profile() {
|
|||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setFormData({
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
username: user.username || '',
|
||||
bio: user.bio || '',
|
||||
favorite_games: user.favorite_games || []
|
||||
first_name: user?.first_name || '',
|
||||
last_name: user?.last_name || '',
|
||||
username: user?.username || '',
|
||||
bio: user?.bio || '',
|
||||
favorite_games: user?.favorite_games || []
|
||||
});
|
||||
setMessage({ type: '', text: '' });
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ export default function Scanner() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout user={user}>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ export default function Settings() {
|
|||
const router = useRouter();
|
||||
|
||||
// User state
|
||||
const [user, setUser] = useState({
|
||||
email: 'me@randallstillwell.com',
|
||||
role: 'admin'
|
||||
});
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// Settings state
|
||||
const [settings, setSettings] = useState({
|
||||
|
|
@ -303,7 +300,7 @@ export default function Settings() {
|
|||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={user.email}
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
className="input-field flex-1 opacity-50 cursor-not-allowed"
|
||||
/>
|
||||
|
|
|
|||
81
test/components/Layout.test.js
Normal file
81
test/components/Layout.test.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup, screen } from '@testing-library/react';
|
||||
|
||||
// Mock next/router so useRouter() does not crash without a RouterContext.
|
||||
// Layout reads `router.pathname` only; the rest of the surface (`prefetch`,
|
||||
// `events`, `push`) is for next/link's internals — provide stubs so prefetch
|
||||
// does not throw when <Link> mounts.
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
pathname: '/',
|
||||
asPath: '/',
|
||||
query: {},
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn().mockResolvedValue(undefined),
|
||||
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock next/link to a plain <a>. The real next/link triggers prefetch on
|
||||
// mount via the router; bypassing it removes a class of jsdom flake without
|
||||
// changing the rendered DOM that the assertions inspect.
|
||||
vi.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children, ...rest }) => {
|
||||
return (
|
||||
<a href={typeof href === 'string' ? href : ''} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the theme context so useTheme() does not require a ThemeProvider.
|
||||
vi.mock('../../lib/theme-context', () => ({
|
||||
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
import Layout from '../../components/Layout';
|
||||
|
||||
describe('Layout — logged-out rendering (regression: P0 #7)', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('does NOT render the maintainer email when no user prop is passed', () => {
|
||||
const { container } = render(<Layout>page body</Layout>);
|
||||
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
||||
});
|
||||
|
||||
it('does NOT render the maintainer email when user is null', () => {
|
||||
const { container } = render(<Layout user={null}>page body</Layout>);
|
||||
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
||||
});
|
||||
|
||||
it('renders a Sign-in link to /login when user is null', () => {
|
||||
render(<Layout user={null}>page body</Layout>);
|
||||
const links = screen.getAllByRole('link', { name: /sign in/i });
|
||||
expect(links.length).toBeGreaterThanOrEqual(1);
|
||||
// Both desktop sidebar + mobile drawer render UserProfileDropdown,
|
||||
// so we expect TWO Sign-in links (one per copy).
|
||||
for (const link of links) {
|
||||
expect(link.getAttribute('href')).toBe('/login');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the supplied user email when user is an object', () => {
|
||||
const { container } = render(
|
||||
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
||||
);
|
||||
expect(container.textContent).toContain('foo@bar.com');
|
||||
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
||||
});
|
||||
|
||||
it('does NOT render a "Guest" placeholder when logged out', () => {
|
||||
// Decision A says the logged-out copy is "Sign in", not "Guest".
|
||||
// This test prevents a future revert that ships "Guest" as the default
|
||||
// (which would still hide the maintainer email but skip the CTA).
|
||||
const { container } = render(<Layout user={null}>page body</Layout>);
|
||||
expect(container.textContent).not.toContain('Guest');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,18 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
// Repo-wide convention: React components live in .js files (Next.js
|
||||
// pages-router style — see AGENTS.md Gotcha #9, "typescript is a devDep
|
||||
// but the source is JavaScript-only"). Vite's default esbuild loader
|
||||
// treats .js as plain JS and rejects JSX, which breaks any test that
|
||||
// imports a component (e.g. test/components/Layout.test.js). This widens
|
||||
// the JSX loader to .js so component tests work without renaming files.
|
||||
esbuild: {
|
||||
loader: 'jsx',
|
||||
jsx: 'automatic',
|
||||
include: /\.[jt]sx?$/,
|
||||
exclude: [],
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue