Files
summercms/.planning/phases/10-admin-vue-spa/10-04-PLAN.md
2026-09-27 14:11:07 +02:00

221 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 10-admin-vue-spa
plan: 04
type: execute
wave: 4
depends_on: [10-03]
files_modified:
- admin/src/components/relation/RelationManager.vue
- admin/src/components/relation/RelationPickerModal.vue
- admin/src/components/form/registry.ts
- admin/src/components/form/FormTabs.vue
- admin/src/views/FormView.vue
- admin/src/components/list/DataTable.vue
- admin/src/components/shell/AppShell.vue
- admin/src/components/shell/PluginRail.vue
- admin/src/components/shell/SectionPanel.vue
- admin/src/components/shell/SectionFlyout.vue
- admin/src/components/shell/UserMenu.vue
- admin/src/components/shell/Breadcrumbs.vue
- admin/src/components/ui/Toast.vue
- admin/src/state/useSidebar.ts
- admin/src/state/useAuth.ts
- admin/src/state/useToasts.ts
- admin/src/app/theme.ts
- admin/src/main.ts
- admin/src/styles/main.css
- admin/tests/fixtures/widgets.relation-schema.json
- admin/tests/fixtures/widgets.relation-linked.json
- admin/tests/fixtures/widgets.relation-candidates.json
- admin/tests/smoke/relation.smoke.test.ts
- admin/tests/smoke/shell.smoke.test.ts
- phrasebook/backend/lang/en/lang.yaml
- phrasebook/backend/lang/pl/lang.yaml
- boardwalk/dist/**
autonomous: true
requirements: [ADMIN-06]
estimate:
tokens: 80000
raw_tokens: 80000
tasks: 2
confidence: low
must_haves:
truths:
- "Per D-05/D-06 and success criterion 3, a relation-manager field renders only when editing an existing record (never on create), inside its tab, and lists the linked rows from GET /{id}/relations/{name} with the relation schema's view columns, search (300 ms debounce) and row selection."
- "In the RelationPickerModal (role=dialog, aria-modal, focus trapped, Esc closes, focus returns to the trigger), the admin searches candidates from GET /{id}/relations/{name}/candidates five per page with the owner excluded by the server, selects several across pages, and Dodaj (N) is disabled at N = 0; confirming POSTs link with the selected ids, refreshes the linked list and toasts the plural linked message."
- "Unlinking selected rows asks for confirmation with the plural unlinkConfirm message, POSTs unlink with the ids, refreshes and toasts the plural unlinked message; toolbar buttons follow the relation schema's view.toolbarButtons."
- "Per D-06/D-10, below about 1100px or after the admin collapses it, the section panel hides and only the rail remains; the manual choice persists in localStorage; hovering or focusing a rail item, or pressing Enter or ArrowDown on it, opens the SectionFlyout (role=menu) that closes on Esc or about 200 ms after mouse-leave and returns focus to the rail item."
- "The UserMenu shows initials, name and the role name from /auth/me (name and role hidden at tablet width) and a Wyloguj item that calls /auth/logout and always ends on the login screen; breadcrumbs show plugin, controller and record labels."
- "Dark mode follows prefers-color-scheme by toggling the .dark class on the document element, using the design's dark tokens; the sidebar stays dark in both modes."
- statement: "[flagged assumption A6] Dark mode follows the system preference only; the design has no toggle control."
verification: backstop
artifacts:
- path: "admin/src/components/relation/RelationManager.vue"
provides: "Linked list, search, selection, link and unlink flows for relation-manager fields"
- path: "admin/src/components/relation/RelationPickerModal.vue"
provides: "Candidate search, five-per-page paging, multi-select and Dodaj (N)"
- path: "admin/src/components/shell/SectionFlyout.vue"
provides: "Collapsed-rail flyout menu"
- path: "admin/src/components/shell/UserMenu.vue"
provides: "User menu with role and logout"
- path: "admin/src/state/useSidebar.ts"
provides: "Collapsed state from viewport and persisted manual choice"
key_links:
- from: "admin/src/components/relation/RelationPickerModal.vue"
to: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"
via: "typed openapi-fetch call with {ids}"
pattern: "relations/{name}/link"
- from: "admin/src/components/form/registry.ts"
to: "admin/src/components/relation/RelationManager.vue"
via: "relation-manager type registration"
pattern: "relation-manager"
- from: "admin/src/components/shell/UserMenu.vue"
to: "POST /auth/logout"
via: "useAuth.logout clears state and routes to login"
pattern: "auth/logout"
prohibitions:
- "[flagged-unverified] The SPA must not filter relation candidates itself (for example hiding the owner); exclusion and scoping come only from the server's candidates endpoint."
- "[flagged-unverified] localStorage must hold only the sidebar preference; no token, profile or record data."
---
## Phase Goal
**As a** backend administrator, **I want to** open my project's own admin URL, log in and manage Albums, Artists, Collections, Genres and Styles through schema-driven lists, forms and the relation manager, **so that** I can administer the catalogue from one Go binary without the WinterCMS backend.
<objective>
Complete the admin experience: the relation manager with its picker modal (Collections editors: search, link, unlink) and the shell polish from the design (collapsible panel with flyout below 1100px, user menu with role and logout, breadcrumbs, dark mode, toast polish).
Purpose: Success criterion 3 (the Collections relation manager searches, links and unlinks an editor) plus the remaining D-06 fidelity items. Decisions implemented: D-05 (relation-manager renderer), D-06, D-10 (active rail and flyout), D-11 (icons already mapped in Plan 10-01 are reused); D-28 fixes this plan's scope.
Output: relation components, shell components, sidebar/theme state, smoke tests, rebuilt `boardwalk/dist`.
Repo: summercms.go only. Commit code separately from planning docs; never add co-author tags.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/10-admin-vue-spa/10-CONTEXT.md
@.planning/phases/10-admin-vue-spa/design/README.md
@.planning/phases/10-admin-vue-spa/10-03-SUMMARY.md
@admin/src/api/schema.d.ts
@admin/src/views/FormView.vue
@admin/src/components/form/registry.ts
@admin/src/components/list/DataTable.vue
@admin/src/components/shell/AppShell.vue
@cabana/relation.go
<interfaces>
Relation API (Phase 9, prefix-relative, typed in schema.d.ts): `GET /{vendor}/{plugin}/{controller}/schema/relation/{name}` (label, view{list.columns, toolbarButtons, showSearch}, manage{...}, messages), `GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}` and `.../candidates` (list contract: search, sort, dir, page, per_page; ListEnvelope of records), `POST .../link` and `.../unlink` with `{ids}` returning `{linked}` or `{removed}`. Relation messages keys from Plan 10-02: link, linkHint, candidateSearch, linked, unlinkSelected, unlinkConfirm, unlinked, empty.
Auth: `POST /auth/logout` (cookie plus X-Requested-With from the client middleware), `/auth/me` profile with role {id, code, name}.
</interfaces>
</context>
## Artifacts this phase produces
- `RelationManager.vue`, `RelationPickerModal.vue`; `relation-manager` registered in `registry.ts`
- `SectionFlyout.vue`, `UserMenu.vue`, `Breadcrumbs.vue`; updated `AppShell`, `PluginRail`, `SectionPanel`, `Toast`
- `useSidebar()` (viewport below 1100px via matchMedia plus persisted manual flag, localStorage key `summer-admin.sidebar`), `useAuth().logout()`, `app/theme.ts` (`applyColorScheme()`)
- New `backend::lang` keys for shell and relation strings; smoke tests `tests/smoke/relation.smoke.test.ts`, `tests/smoke/shell.smoke.test.ts`
<tasks>
<task type="tracer">
<name>Task 1: An admin searches, links and unlinks editors on a Collection through the relation manager</name>
<files>admin/src/components/relation/RelationManager.vue, admin/src/components/relation/RelationPickerModal.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormTabs.vue, admin/src/views/FormView.vue, admin/src/components/list/DataTable.vue, admin/tests/fixtures/widgets.relation-schema.json, admin/tests/fixtures/widgets.relation-linked.json, admin/tests/fixtures/widgets.relation-candidates.json, admin/tests/smoke/relation.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/**</files>
<read_first>admin/src/views/FormView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FormTabs.vue, admin/src/components/list/DataTable.vue, admin/src/components/ui/ConfirmDialog.vue, admin/src/app/i18n.ts, admin/src/api/schema.d.ts, cabana/relation.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, .planning/phases/10-admin-vue-spa/design/README.md (screen 5)</read_first>
<action>Per D-05/D-06, register `relation-manager` in `registry.ts` with `RelationManager.vue`. FormView passes the record id and mode; the field renders only in update mode (FormTabs never shows a tab whose only fields are relation managers on create), always full width.
`RelationManager.vue`: load `schema/relation/{name}` once (label, view columns, `view.toolbarButtons`, `view.showSearch`, messages). Section header: the relation label at 17px/700 with the relation-manager field's `comment` as helper text when the YAML declares one, a 220px by 38px search input (300 ms debounce, placeholder from the list search prompt default), and the toolbar buttons in declared order at 38px height: `link` as the primary `user-plus` button labelled with the `link` message, `unlink` as the danger outline `user-minus` button labelled with the `unlinkSelected` message and disabled without a selection. The linked list reuses `DataTable` with 56px rows inside a 12px-radius bordered container: selection checkbox, the first view column as an initials avatar (30px) plus text at 600 weight, the remaining view columns muted, pagination from the list meta, and the relation `empty` message when nothing is linked. Unlink confirms with the plural `unlinkConfirm` message, POSTs `unlink` with `{ids}`, clears the selection, reloads and toasts the plural `unlinked` message.
`RelationPickerModal.vue` (Reka Dialog): 560px wide, radius 20, padding 24, backdrop from the overlay token, `role="dialog"` with `aria-modal`, title from the `link` message (20px/700), helper from `linkHint`, a 34px close button (`x`, subtle background, labelled from `t`), a 44px search input with autofocus and the `candidateSearch` placeholder, and the candidate list as a `role="listbox"` with `aria-multiselectable="true"` loaded from `.../candidates` with `per_page=5`, the search term and the page. Each option is a 54px row (radius 12, border): a 32px initials avatar, the first manage column at 600 weight above the second manage column at 13px muted, and a checkbox; a selected option has the sel background and a `#e0b020` border. Selection is a set kept across pages and cleared on close. The pager shows `:from–:to z :total` and 34px previous and next buttons (radius 9). The footer has two equal-width buttons: Anuluj (outline) and `Dodaj (:count)` (primary, disabled when the set is empty). Confirming POSTs `link` with the ids, closes, reloads the linked list and toasts the plural `linked` message. Esc closes, focus stays inside while open, and focus returns to the triggering button on close. The SPA never filters candidates itself; the owner exclusion is the server's.
Add the new keys to both backend lang files, neutral relation fixtures (`acme.demo.widgets` relation `members` with columns `name` and `email`), and `tests/smoke/relation.smoke.test.ts` (hidden on create; linked list render and search; unlink confirm and body; modal paging at five, selection across pages, disabled at zero, link body, focus return, Esc), then rebuild `boardwalk/dist`.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke/relation.smoke.test.ts &amp;&amp; go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin(RelationSchema|Link|Unlink|CrossScope|RelationEdges)$' -count=1 -v)</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; a go test output lacks its "--- PASS" lines or shows "no tests to run" or SKIP; check-admin-dist.sh prints a diff.</fails_when>
<human-check>
<test>With `summer serve` running in ../fonoteka.go, open http://localhost:8080/plytadmin, log in, open Kolekcje, edit a collection and switch to the Edytorzy tab; search, add two editors in the modal, then remove one.</test>
<expected>The tab is absent on the create form; the modal lists users five per page without the owner, Dodaj (2) links both and the toast confirms; removing asks for confirmation and the row disappears; focus returns to the Dodaj button after closing the modal.</expected>
<why_human>Focus management and the real link/unlink round trip in a browser are outside Vitest's DOM (D-23: no Playwright).</why_human>
</human-check>
</verify>
<acceptance_criteria>
- The relation smoke test passes and covers every behaviour listed at the end of the action.
- `grep -c "'relation-manager'" admin/src/components/form/registry.ts` prints 1.
- `grep -c 'aria-multiselectable' admin/src/components/relation/RelationPickerModal.vue` prints at least 1.
- The fonoteka Collections relation suites still pass through the prefix.
</acceptance_criteria>
<done>On an existing Collection an admin can search editors, add several through the picker and remove selected ones, with confirmations and toasts.</done>
</task>
<task type="auto">
<name>Task 2: The shell matches the design: collapsible panel with flyout, user menu with logout, breadcrumbs and dark mode</name>
<files>admin/src/components/shell/AppShell.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/SectionPanel.vue, admin/src/components/shell/SectionFlyout.vue, admin/src/components/shell/UserMenu.vue, admin/src/components/shell/Breadcrumbs.vue, admin/src/components/ui/Toast.vue, admin/src/state/useSidebar.ts, admin/src/state/useAuth.ts, admin/src/state/useToasts.ts, admin/src/app/theme.ts, admin/src/main.ts, admin/src/styles/main.css, admin/tests/smoke/shell.smoke.test.ts, phrasebook/backend/lang/en/lang.yaml, phrasebook/backend/lang/pl/lang.yaml, boardwalk/dist/**</files>
<read_first>admin/src/components/shell/AppShell.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/SectionPanel.vue, admin/src/state/useNavigation.ts, admin/src/state/useAuth.ts, admin/src/app/icons.ts, admin/src/styles/main.css, .planning/phases/10-admin-vue-spa/design/README.md (screen 2, Top header, Transitions, Responsive)</read_first>
<action>Per D-06/D-10:
(1) Sidebar state: `src/state/useSidebar.ts` combines `matchMedia('(max-width: 1099px)')` (forces collapsed without overwriting the manual preference) with a manual flag persisted in localStorage under `summer-admin.sidebar` (boolean only). SectionPanel (224px) gets its header with the plugin label at 16px/700 and a 30px "Zwiń menu" button (`panel-left-close`); when collapsed only the 80px rail remains and a 40px "Rozwiń menu" button (`panel-left-open`) appears above Ustawienia.
(2) Flyout: `SectionFlyout.vue` opens in collapsed mode when a rail item is hovered or focused, or on Enter or ArrowDown: 230px wide, 8px right of the rail, aligned with the header area, surface background, border, radius 14, padding 8, shadow `0 16px 40px rgba(20,27,45,.18)`, a bold plugin title and 38px items (radius 9) with the panel's idle and active styles; `role="menu"` with menuitems, closes on Esc or about 200 ms after mouse-leave, and returns focus to the rail item. Clicking a rail item navigates to its first permitted side-menu controller (or the entry's controller); the active rail item is the one owning the route's vendor and plugin.
(3) Header: 64px surface header with `Breadcrumbs.vue` (plugin label, controller label, record title; parent crumbs are muted links, the current crumb is text colour at 600; `chevron-right` 14px separators) and `UserMenu.vue` (Reka DropdownMenu): a 44px button with a 34px accent avatar of initials (first and last name, else login), the name (600) above the role name from `/auth/me` (12px muted; both hidden at tablet width) and `chevron-down`; the 280px menu (radius 14, padding 8) holds a header with a 40px avatar, the bold name and muted email, a divider and a Wyloguj item (`log-out`). `useAuth().logout()` POSTs `/auth/logout`, clears the in-memory user and navigation, and routes to login even when the call fails.
(4) Theme and polish: `src/app/theme.ts` applies the `.dark` class on the document element from `prefers-color-scheme: dark` and follows changes (A6: no toggle); the rail keeps the dark side colour in both modes with the `#222b40` right border in dark mode. Toasts keep one queue and show the most recent first; transitions are 150 ms ease-out for hover and background and 200 ms fade plus scale from 0.98 for dialogs; no decorative animation.
(5) Add the new keys to both backend lang files and `tests/smoke/shell.smoke.test.ts` (persisted collapse and viewport-forced collapse, flyout open by keyboard and Esc with focus return, user menu shows role and logout calls the API then routes to login even on failure, breadcrumbs for a record route, dark class follows a mocked matchMedia), then rebuild `boardwalk/dist`.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test -- tests/smoke &amp;&amp; go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh</automated>
<fails_when>Any command exits non-zero; vitest prints "No test files found" or any failed test; the go test output lacks "--- PASS: TestPhase10SPAKeysResolve" or shows "no tests to run"; check-admin-dist.sh prints a diff.</fails_when>
<human-check>
<test>With `summer serve` running in ../fonoteka.go, compare http://localhost:8080/plytadmin (login, Albumy list, an album form, Kolekcje editors with the modal open) against `.planning/phases/10-admin-vue-spa/design/Direction C v2.dc.html` at 1280px and at about 900px width, in light and in dark system mode; log in once as a limited admin (only golem15.fonoteka.access_genres) and once as a superuser.</test>
<expected>Tokens, typography, radii, control heights, focus rings and copy match the design; below about 1100px only the rail shows and the flyout opens from it; the user menu shows the role and Wyloguj returns to login; the limited admin sees only Gatunki in the Fonoteka side menu while the superuser sees every entry.</expected>
<why_human>Visual fidelity and responsive behaviour are judged against the design reference; D-23 excludes browser e2e.</why_human>
</human-check>
</verify>
<acceptance_criteria>
- All smoke tests under `tests/smoke` pass, including the shell cases listed in item (5).
- `grep -rn 'localStorage' admin/src | grep -v 'summer-admin.sidebar' | grep -v 'useSidebar'` prints nothing.
- `grep -c 'auth/logout' admin/src/state/useAuth.ts` prints at least 1.
</acceptance_criteria>
<done>The admin shell behaves like the design at desktop and tablet widths in light and dark mode, and an admin can see their role and log out.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| SPA → relation link/unlink | Selected ids cross into pivot writes |
| Browser storage | Anything persisted survives the session on a shared machine |
| UserMenu → logout | The session must end even when the network call fails |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-21 | Elevation of Privilege | relation link of candidates outside scope (owner, inactive users) | medium | transfer | Enforced server-side by Phase 9 (RelationExtendManageQuery, ExcludedRelatedIDs, TestCollectionsAdminForgedPivot/CrossScope); the SPA only posts ids chosen from the server's candidates and runs those suites as a regression in Task 1. |
| T-10-22 | Information Disclosure | localStorage | low | mitigate | Only the sidebar boolean is stored under summer-admin.sidebar; acceptance grep rejects any other localStorage use. |
| T-10-23 | Spoofing | logout on a shared browser | medium | mitigate | logout POSTs /auth/logout (server blacklists the jti and expires the HttpOnly cookie, Plan 10-01) and the SPA clears state and routes to login even on failure; shell smoke test covers both paths. |
| T-10-SC | Tampering | npm dependencies | high | mitigate | No new package; npm ci against the lockfile approved in Plan 10-01. |
</threat_model>
<verification>
Run `npm --prefix admin run typecheck && npm --prefix admin test`, `go test ./phrasebook -count=1`, `scripts/check-admin-dist.sh`, and `(cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestCollectionsAdmin' -count=1)`. A non-zero exit, a failed or missing smoke test, or a printed diff fails the plan.
</verification>
<success_criteria>
- The Collections editors relation manager searches, links and unlinks (SC-3).
- The shell matches the design's responsive, flyout, user menu, breadcrumb and dark-mode behaviour (D-06).
</success_criteria>
<output>
Create `.planning/phases/10-admin-vue-spa/10-04-SUMMARY.md` when done.
</output>