221 lines
21 KiB
Markdown
221 lines
21 KiB
Markdown
---
|
||
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 && npm --prefix admin test -- tests/smoke/relation.smoke.test.ts && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && (cd ../fonoteka.go && 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 && npm --prefix admin test -- tests/smoke && go test ./phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && 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>
|