diff --git a/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md new file mode 100644 index 00000000..66813765 --- /dev/null +++ b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md @@ -0,0 +1,550 @@ +--- +phase: "19.6.1" +slug: "backend-administrators-screen" +status: approved +reviewed_at: "2026-10-21" +shadcn_initialized: false +preset: none +created: "2026-10-21" +--- + +# Phase 19.6.1 — UI Design Contract + +> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker. + +This phase adds **no new screen design**. The Administrators and Roles screens are YAML-declared cabana controllers (`columns.yaml`, `fields.yaml`, `config_*.yaml`) rendered by the existing generic views in `admin/` (`ListView.vue`, `FormView.vue`, `PreviewView.vue`, `SettingsIndexView.vue`). The contract therefore fixes four things: + +1. **What the YAML declares**: columns, filters, form tabs and field order, preview, record and bulk actions, settings entries. These are layout decisions made with existing components. +2. **Two narrow SPA additions (D-21)**: + - a field `trigger` (`action: disable|hide`, `field`, `condition: checked|unchecked`) evaluated in `FormView`/`PreviewView`; + - per-record locked permission values rendered by `PermissionEditorField.vue`. +3. **All copy** (en + pl) for both screens, the settings entries, the permission catalog, record/bulk actions, the preview status hint and every privilege refusal (D-05, D-07, D-08, D-09, D-17, D-19, D-20). +4. **Where each refusal surfaces** in the existing UI (banner, field line, toast), so the server picks an error shape the SPA already displays. + +The SPA has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` on Reka UI primitives. **This contract adds no tokens, colours, fonts, radii, shadows or icons outside the existing set.** Every class string below is copied from an existing component (`FormField.vue`, `PermissionEditorField.vue`, `RelationField.vue`, `RecordActions.vue`, `FormErrorBanner.vue`, `RowStateBadges.vue`, `main.css` callout kit). + +Sources: 19.6.1-CONTEXT.md D-01..D-22 (locked); 19.6.1-RESEARCH.md (Patterns 1-6, Pitfalls, Open Questions 1-4 now resolved by D-19..D-21); approved house conventions from the 12.1, 12.2 and 16.3 UI-SPECs; WinterCMS `modules/backend/models/{user,userrole}/*.yaml` and `lang/{en,pl}/lang.php` (`user.*` block) for structure and wording; the codebase scan listed per section. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | none (no shadcn; Vue 3 SPA with its own Direction C system. The shadcn gate does not apply to a Vue codebase with an established system, the same ruling as Phases 10.1, 12.1, 12.2, 16.3) | +| Preset | not applicable | +| Component library | Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/` | +| Icon library | `@lucide/vue` 1.17.0 (named imports only). Settings tiles use the registry names `users` and `shield-check` through `admin/src/app/icons.ts`; the trigger note uses `Info` 14px; locks use the existing `Lock` 14px | +| Font | DM Sans 400/600/700 (self-hosted `@fontsource/dm-sans`); no DM Mono in this phase | + +--- + +## Component Inventory + +Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-21. +Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 59 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 053c643) — 2026-10-21. + +Icon presence verified by `node --input-type=module -e "import * as l from '@lucide/vue'; for (const n of ['Lock','Info','CircleAlert','Users','ShieldCheck']) console.log(n, n in l)"` (run in `admin/`) against @lucide/vue@1.17.0 on 2026-10-21: all exported. `users` and `shield-check` are already mapped in `admin/src/app/icons.ts`. + +This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path. + +| Component | Import path | Notes | +|-----------|-------------|-------| +| ListView | `admin/src/views/ListView.vue` | Both lists. Toolbar create/delete, search, filters, bulk actions menu, row-state badges. No change. | +| FormView | `admin/src/views/FormView.vue` | Both forms. Gains trigger evaluation (Contract C6) and threads record permission locks (C7). | +| PreviewView | `admin/src/views/PreviewView.vue` | Administrators preview with the `hint` header partial and `RecordActions`. Gains `hide` trigger evaluation only (C6). | +| FormField | `admin/src/components/form/FormField.vue` | Gains the trigger note line (C6). Label, comment, error and notice markup unchanged. | +| PermissionEditorField | `admin/src/components/form/fields/PermissionEditorField.vue` | Radio mode (per-user overrides) and checkbox mode (role grants). Gains record locks (C7). | +| SwitchField | `admin/src/components/form/fields/SwitchField.vue` | The `is_superuser` toggle card. No change. | +| RelationField | `admin/src/components/form/fields/RelationField.vue` | The `role` belongsTo dropdown. Its existing locked-option handling (`Lock` icon, `form.locked_note`) renders the D-07 role ceiling. No change. | +| PasswordField | `admin/src/components/form/fields/PasswordField.vue` | `password` and `password_confirmation`. No change. | +| RecordActions | `admin/src/components/form/RecordActions.vue` | Preview footer actions, owner's and contributed (D-14, D-15). No change. | +| FormErrorBanner | `admin/src/components/form/FormErrorBanner.vue` | 403 forbidden banner and 422 banner (C8). No change. | +| RowStateBadges | `admin/src/components/list/RowStateBadges.vue` | `deleted` and `disabled` states on the Administrators list. No change. | +| PartialHost + `.summer-callout` kit | `admin/src/components/partial/PartialHost.vue`, `main.css` | Preview status hint (`_hint.htm`, html/template, server-rendered). No change. | +| ConfirmDialog + `useConfirm` | `admin/src/components/ui/ConfirmDialog.vue`, `confirm.ts` | Record, bulk and delete confirmations. No change. | +| `showToast` | `admin/src/state/useToasts.ts` | Action results and delete refusals. No change. | +| `editablePayload`, `permissionValues` | `admin/src/components/form/formState.ts`, `control.ts` | Payload rules change for triggered fields (C6) and record-locked codes (C7). | + +New SPA code is limited to: trigger evaluation helpers (suggested `admin/src/components/form/trigger.ts`), the note line in `FormField.vue`, the record-lock prop/inject path into `PermissionEditorField.vue`, and the regenerated `admin/src/api/schema.d.ts`. No new `.vue` file is required. + +--- + +## Spacing Scale + +Declared values for new layout in this phase (multiples of 4): + +| Token | Value | Tailwind | Usage | +|-------|-------|----------|-------| +| xs | 4px | `gap-1` | not used by new markup | +| sm | 8px | `gap-2` | Trigger note: icon-to-text gap (copied from the `RelationField` locked note `gap-2`) | +| md | 16px | `px-4` | Permission editor row padding (existing, unchanged) | +| lg | 24px | `gap-x-6` | Form grid column gap (existing `FormGrid`, unchanged) | +| xl | 32px | `px-8` | Sticky footer padding (existing, unchanged) | +| 2xl | 48px | — | not used | +| 3xl | 64px | — | not used | + +Exceptions (inherited from Direction C and already approved as locked in 16.3 on 2026-10-11; re-used verbatim, none introduced): label-to-control gap 6px (`gap-1.5` in `FormField`); form row gap 22px (`gap-y-[22px]` in `FormGrid`); card padding 28px (`p-7`); page stack 20px (`gap-5`); footer button gap 10px (`gap-2.5`); toggle card / callout vertical padding 14px (`py-3.5`); callout padding 14x18 (`.summer-callout`, `px-[18px] py-3.5` banner); permission row 12px vertical padding (`py-3`) and 56px minimum row height. + +The trigger note line sits inside `FormField`'s existing `flex-col gap-1.5` stack, so it adds no new spacing value. + +--- + +## Typography + +New markup uses exactly three styles; everything else is rendered by existing components unchanged. + +| Role | Size | Weight | Line Height | Used by | +|------|------|--------|-------------|---------| +| Body | 14px | 400 | 1.5 | Field values, callout text, toasts, banner text (all existing) | +| Label | 14px | 600 | 1.5 | Field labels, permission row labels, callout titles, button text (all existing) | +| Meta | 13px | 400 | 1.5 | **New:** the trigger note line and the record-lock note line. Existing: field comments, error lines, the locked note | +| Page heading (inherited) | 24px | 700 | 1.2 (`tracking-[-0.02em]`) | Record title in `FormView`/`PreviewView` header, unchanged | + +Weights: new content uses 400 and 600 only. 700 appears only in the inherited page heading (locked system-level exception, as in 16.3). No 12px text is added (the existing 12px row-state badge is reused unchanged). + +--- + +## Color + +All colours are existing CSS variables (light / dark from `main.css`). Hard-coded hex values are forbidden in new markup. + +| Role | Value (light / dark) | Usage | +|------|----------------------|-------| +| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page, form card, list table, permission editor rows | +| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6 | Permission section headers, segmented control track, read-only boxes, the trigger note and lock note text, `Lock`/`Info` icons, the "Deleted" row badge outline | +| Accent (10%) | `primary` #22304d / #fcd34d; `sel` #fdf3cf / #3a3622; `ring` | See reserved list | +| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | Form footer Delete (danger outline), delete confirm buttons, forbidden and 422 banners, field error lines, the "deleted" preview callout, danger toasts | +| Semantic OK | `ok-bg` / `ok-text` | Existing "Allow" segment checked state and the "Yes" switch pill on preview; unchanged | + +Accent reserved for (exhaustive for this phase): +1. `primary` fill: the single primary button per screen that already exists: footer **Save** on forms, **Edit administrator** on the preview, **New administrator** / **New role** list toolbar create. No new primary button is added. +2. `primary` track: the `is_superuser` switch when on (existing `SwitchField`), and checked role-grant checkboxes in the permission editor (existing). +3. `sel` tint: the warning variant of the preview status callout (deactivated account; your own account), via `.summer-callout--warning`. +4. `ring` (3px): focus-visible on every focusable element (base style), never removed. + +Accent is never used for: record action buttons (outline), the trigger note, lock icons, row-state badges, settings tiles, or links. + +--- + +## Copywriting Contract + +All copy lives in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`backend::lang.*`). New groups: `user:` (with a nested `role:` block, mirroring Winter's `backend::lang.user.*` and `backend::lang.user.role.*` names so ported plugins and docs keep the same keys) and `permissions:`. Additions to existing groups: `form.trigger_*`, `permissioneditor.locked_*`, `settings.category_system`. Placeholders use `:name` / `:count` / `:field`. Plural texts are CLDR maps (`one`/`other` in en; `one`/`few`/`many`/`other` in pl), never pipe strings. Polish is the design language; English values follow the same meaning. + +Validation messages that already exist (`required`, `min.string`, `max.string`, `confirmed`, `unique`) are reused for password and name rules; no new copy for them. + +### Primary CTAs and key states + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Controller record name (`config_form.yaml` `name`) | `user.name` / `user.role.name` | Administrator / Role | Administrator / Rola | +| Administrators list title | `user.list_title` | Administrators | Administratorzy | +| Primary CTA, Administrators (toolbar create; create-form title) | `user.new` | New administrator | Nowy administrator | +| Preview primary (Edit) | `user.edit` | Edit administrator | Edytuj administratora | +| Preview subtitle | `user.preview` | Administrator details | Szczegóły administratora | +| Roles list title | `user.role.list_title` | Roles | Role | +| Primary CTA, Roles (toolbar create; create-form title) | `user.role.new` | New role | Nowa rola | +| Role edit title | `user.role.edit` | Edit role | Edytuj rolę | +| Save, Save and close, Cancel, Delete | existing `form.*` | unchanged | unchanged | +| Empty state, Administrators (`noRecordsMessage`) | `user.list_empty` | No administrators in this view. Clear the search and filters, or create an administrator. | Brak administratorów w tym widoku. Wyczyść wyszukiwanie i filtry lub utwórz administratora. | +| Empty state, Roles (`noRecordsMessage`) | `user.role.list_empty` | No roles in this view. Clear the search, or create a role. | Brak ról w tym widoku. Wyczyść wyszukiwanie lub utwórz rolę. | +| Empty search (both lists) | existing `messages.list.empty_search` / `empty_search_hint` | unchanged | unchanged | +| Error state, list or form load failed | existing `list.load_failed`, `form.load_failed` | unchanged (also the D-03 404 for a hidden superuser) | unchanged | +| Error state, save failed (non-422/403) | existing `form.error_generic` | unchanged | unchanged | +| Saved toast | existing `messages.form.saved` | unchanged | unchanged | + +### Settings entries and permission catalog (D-01, D-02) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Settings category | `settings.category_system` | System | System | +| Administrators tile label | `user.menu_label` | Administrators | Administratorzy | +| Administrators tile description | `user.menu_description` | Manage administrator accounts, their roles and permissions. | Zarządzaj kontami administratorów, ich rolami i uprawnieniami. | +| Roles tile label | `user.role.menu_label` | Roles | Role | +| Roles tile description | `user.role.menu_description` | Define roles and the permissions they grant. | Określ role i uprawnienia, które nadają. | +| Permission tab (catalog section) | `permissions.tab_system` | System | System | +| `backend.manage_users` label | `permissions.manage_users` | Manage administrators | Zarządzanie administratorami | +| `backend.manage_users` comment | `permissions.manage_users_comment` | Create, edit, deactivate and delete administrator accounts. | Tworzenie, edycja, dezaktywacja i usuwanie kont administratorów. | +| `backend.manage_roles` label | `permissions.manage_roles` | Manage roles | Zarządzanie rolami | +| `backend.manage_roles` comment | `permissions.manage_roles_comment` | Create and edit roles and the permissions they grant. | Tworzenie i edycja ról oraz uprawnień, które nadają. | + +### Administrators: list + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Column / filter: login | `user.login` | Login | Login | +| Column: first name | `user.first_name` | First name | Imię | +| Column: last name | `user.last_name` | Last name | Nazwisko | +| Column: email | `user.email` | Email | E-mail | +| Column / filter: role | `user.role_field` | Role | Rola | +| Column / filter: last sign-in | `user.last_login` | Last sign-in | Ostatnie logowanie | +| Column / filter: superuser (superusers only) | `user.superuser` | Superuser | Superużytkownik | +| Column (invisible): created | `user.created_at` | Created | Utworzono | +| Filter: active | `user.activated` | Active | Aktywny | +| Filter: deleted (scope) | `user.filter_deleted` | Deleted | Usunięci | +| Filter option | `user.filter_deleted_hide` | Hide deleted | Ukryj usuniętych | +| Filter option | `user.filter_deleted_only` | Only deleted | Tylko usunięci | +| Row state `deleted` | `user.state_deleted` | Deleted | Usunięty | +| Row state `disabled` | `user.state_deactivated` | Deactivated | Dezaktywowany | +| Delete selected confirm (danger) | `user.delete_selected_confirm` | one: "Delete :count administrator? They are signed out at once. Deleted accounts can be restored." other: "Delete :count administrators? They are signed out at once. Deleted accounts can be restored." | one: "Usunąć :count administratora? Zostanie od razu wylogowany. Usunięte konta można przywrócić." few/many: "Usunąć :count administratorów? Zostaną od razu wylogowani. Usunięte konta można przywrócić." other: "Usunąć :count administratora? Zostaną od razu wylogowani. Usunięte konta można przywrócić." | +| Deleted toast | existing `messages.list.deleted` | unchanged | unchanged | + +### Administrators: form + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Tab 1 | `user.account` | Account | Konto | +| Tab 2 | `user.permissions` | Permissions | Uprawnienia | +| Password label | `user.password` | Password | Hasło | +| Password comment | `user.password_comment` | At least 4 characters. When editing, leave blank to keep the current password. | Co najmniej 4 znaki. Przy edycji zostaw puste, aby zachować obecne hasło. | +| Password confirmation label | `user.password_confirmation` | Confirm password | Potwierdź hasło | +| Role comment | `user.role_comment` | The role sets this administrator's permissions. Single permissions can be adjusted on the Permissions tab. | Rola określa uprawnienia administratora. Pojedyncze uprawnienia można zmienić w zakładce Uprawnienia. | +| Role empty option | `user.role_none` | No role | Brak roli | +| Superuser comment | `user.superuser_comment` | Grants every permission in the admin, including managing other administrators. Permission overrides below are not used while this is on. | Daje wszystkie uprawnienia w panelu, w tym zarządzanie innymi administratorami. Gdy jest włączone, nadpisania uprawnień poniżej nie są używane. | +| Permission overrides label | `user.permission_overrides` | Permission overrides | Nadpisania uprawnień | +| Permission overrides comment | `user.permission_overrides_comment` | Inherit follows the role. Allow and Deny apply to this administrator only. | Dziedzicz oznacza ustawienie z roli. Zezwól i Odmów dotyczą tylko tego administratora. | +| Delete confirm (form footer, danger) | `user.delete_confirm` | Delete this administrator? They are signed out at once. The account can be restored from the list. | Usunąć tego administratora? Zostanie od razu wylogowany. Konto można przywrócić z listy. | + +### Administrators: preview, record actions and bulk actions (D-10, D-15) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Preview-only field: last sign-in | `user.last_login` | Last sign-in | Ostatnie logowanie | +| Record + bulk action: Activate | `user.activate` | Activate | Aktywuj | +| Activate confirm (record) | `user.activate_confirm` | Activate this administrator? They can sign in again, and their existing API tokens work again. | Aktywować tego administratora? Będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają. | +| Activate confirm (bulk) | `user.activate_selected_confirm` | Activate the selected administrators? They can sign in again, and their existing API tokens work again. | Aktywować zaznaczonych administratorów? Będą mogli się zalogować, a ich dotychczasowe tokeny API znów zadziałają. | +| Activate success (record / bulk) | `user.activated` / `user.activated_selected` | Administrator activated / Selected administrators activated | Aktywowano administratora / Aktywowano zaznaczonych administratorów | +| Record + bulk action: Deactivate | `user.deactivate` | Deactivate | Dezaktywuj | +| Deactivate confirm (record) | `user.deactivate_confirm` | Deactivate this administrator? They are signed out at once and cannot sign in until activated again. | Dezaktywować tego administratora? Zostanie od razu wylogowany i nie zaloguje się, dopóki konto nie zostanie ponownie aktywowane. | +| Deactivate confirm (bulk) | `user.deactivate_selected_confirm` | Deactivate the selected administrators? They are signed out at once and cannot sign in until activated again. | Dezaktywować zaznaczonych administratorów? Zostaną od razu wylogowani i nie zalogują się, dopóki ich konta nie zostaną ponownie aktywowane. | +| Deactivate success (record / bulk) | `user.deactivated` / `user.deactivated_selected` | Administrator deactivated / Selected administrators deactivated | Dezaktywowano administratora / Dezaktywowano zaznaczonych administratorów | +| Record + bulk action: Restore | `user.restore` | Restore | Przywróć | +| Restore confirm (record) | `user.restore_confirm` | Restore this administrator? If the account is active, they can sign in again and their existing API tokens work again. | Przywrócić tego administratora? Jeśli konto jest aktywne, będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają. | +| Restore confirm (bulk) | `user.restore_selected_confirm` | Restore the selected administrators? Active accounts can sign in again, and their existing API tokens work again. | Przywrócić zaznaczonych administratorów? Aktywne konta będą mogły się zalogować, a ich dotychczasowe tokeny API znów zadziałają. | +| Restore success (record / bulk) | `user.restored` / `user.restored_selected` | Administrator restored / Selected administrators restored | Przywrócono administratora / Przywrócono zaznaczonych administratorów | +| Hint: deleted (danger) title / text | `user.trashed_hint_title` / `user.trashed_hint_desc` | This account has been deleted / It cannot be used to sign in. Use Restore to bring it back. | To konto zostało usunięte / Nie można się na nie zalogować. Użyj „Przywróć”, aby je odzyskać. | +| Hint: deactivated (warning) title / text | `user.deactivated_hint_title` / `user.deactivated_hint_desc` | This account is deactivated / It cannot be used to sign in. Use Activate to allow sign-in again. | To konto jest dezaktywowane / Nie można się na nie zalogować. Użyj „Aktywuj”, aby ponownie zezwolić na logowanie. | +| Hint: own account (warning) title / text | `user.self_hint_title` / `user.self_hint_desc` | This is your account / You can change your name, login and email here. Your role, permissions, superuser access and activation can only be changed by another administrator. Change your password in My account. | To Twoje konto / Tutaj zmienisz imię, nazwisko, login i e-mail. Rolę, uprawnienia, dostęp superużytkownika i aktywację może zmienić tylko inny administrator. Hasło zmień w sekcji Moje konto. | + +Contributed record actions (D-14) bring their own label, confirm and success copy from the contributing plugin's phrasebook; this phase adds none. + +### Roles: list and form (D-16, D-17) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Name label / comment | `user.role.name_field` / `user.role.name_comment` | Name / Shown in the role picker on the administrator form. | Nazwa / Wyświetlana na liście ról w formularzu administratora. | +| Code label / comment | `user.role.code_field` / `user.role.code_comment` | Code / A unique identifier used by plugins and the API. The code of a system role cannot be changed. | Kod / Unikalny identyfikator używany przez wtyczki i API. Kodu roli systemowej nie można zmienić. | +| Description label | `user.role.description_field` | Description | Opis | +| Column: system role | `user.role.is_system` | System role | Rola systemowa | +| Permissions tab | `user.permissions` | Permissions | Uprawnienia | +| Granted permissions label / comment | `user.role.permissions_field` / `user.role.permissions_comment` | Granted permissions / Every administrator with this role gets the checked permissions. | Nadane uprawnienia / Każdy administrator z tą rolą otrzymuje zaznaczone uprawnienia. | +| Delete confirm (form footer, danger) | `user.role.delete_confirm` | Delete this role? This cannot be undone. A role that is still assigned to administrators cannot be deleted. | Usunąć tę rolę? Tej operacji nie można cofnąć. Roli przypisanej do administratorów nie można usunąć. | +| Delete selected confirm (danger) | `user.role.delete_selected_confirm` | one: "Delete :count role? This cannot be undone." other: "Delete :count roles? This cannot be undone." | one: "Usunąć :count rolę? Tej operacji nie można cofnąć." few: "Usunąć :count role? Tej operacji nie można cofnąć." many: "Usunąć :count ról? Tej operacji nie można cofnąć." other: "Usunąć :count roli? Tej operacji nie można cofnąć." | + +### Framework additions (D-21) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Trigger note, `condition: checked` | `form.trigger_disabled_on` | Not editable while “:field” is on. | Nie można edytować, gdy „:field” jest włączone. | +| Trigger note, `condition: unchecked` | `form.trigger_disabled_off` | Not editable while “:field” is off. | Nie można edytować, gdy „:field” jest wyłączone. | +| Record lock note: grant declared in code (Roles) | `permissioneditor.locked_by_code` | Granted to this role by a plugin. It cannot be removed here. | Nadane tej roli przez wtyczkę. Nie można go tu odebrać. | +| Record lock note: your own record (Administrators) | `permissioneditor.locked_self` | You cannot change your own permissions. | Nie możesz zmieniać własnych uprawnień. | + +### Privilege refusals (server copy) + +All of these are phrase keys the server puts in a `ForbiddenError` (`Message` and/or `Details`) or a `ValidationError` detail. Contract C8 maps where each one renders. + +| Decision | Key | EN | PL | +|----------|-----|----|----| +| D-05 | `user.superuser_forbidden` | Only a superuser can grant, remove or change superuser access. | Tylko superużytkownik może nadawać, odbierać lub zmieniać dostęp superużytkownika. | +| D-07 role ceiling (`RelationLock.Message`) | `user.role_ceiling` | You can only assign roles whose permissions you have yourself. | Możesz przypisywać tylko role, których uprawnienia sam posiadasz. | +| D-07 permission ceiling | existing `permissioneditor.locked` | You cannot change this permission. | Nie możesz zmienić tego uprawnienia. | +| D-08 own privileged fields | `user.self_privileged` | You cannot change your own role, permissions, superuser access or activation. Ask another administrator. | Nie możesz zmieniać własnej roli, uprawnień, dostępu superużytkownika ani aktywacji. Poproś innego administratora. | +| D-08 delete yourself | `user.self_delete` | You cannot delete your own account. | Nie możesz usunąć własnego konta. | +| D-08 deactivate yourself | `user.self_deactivate` | You cannot deactivate your own account. | Nie możesz dezaktywować własnego konta. | +| D-09 | `user.last_superuser` | This is the last active superuser, so it cannot be deactivated, deleted or lose superuser access. Make another administrator a superuser first. | To ostatni aktywny superużytkownik, więc nie można go dezaktywować, usunąć ani odebrać mu dostępu superużytkownika. Najpierw nadaj ten dostęp innemu administratorowi. | +| D-19 | `user.outranked` | This administrator has permissions you do not have, so you cannot change, deactivate or delete their account. | Ten administrator ma uprawnienia, których nie masz, więc nie możesz zmieniać, dezaktywować ani usuwać jego konta. | +| D-20 | `user.own_password` | Change your own password in My account, where your current password is checked. | Własne hasło zmień w sekcji Moje konto, która sprawdza obecne hasło. | +| D-17 role has admins | `user.role.delete_has_users` | one: "The role “:name” is assigned to :count administrator. Give them another role first. Deleted accounts count too." other: "The role “:name” is assigned to :count administrators. Give them another role first. Deleted accounts count too." | one: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel mu inną rolę. Liczą się też usunięte konta." few/many: "Rola „:name” jest przypisana do :count administratorów. Najpierw przydziel im inną rolę. Liczą się też usunięte konta." other: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel inną rolę. Liczą się też usunięte konta." | +| D-17 system role | `user.role.delete_system` | System roles cannot be deleted. | Ról systemowych nie można usunąć. | +| D-16 system role code | `user.role.code_locked` | The code of a system role cannot be changed. | Kodu roli systemowej nie można zmienić. | +| Role code taken | `user.role.code_taken` | This code is already used by another role. | Ten kod jest już używany przez inną rolę. | +| Login taken | `user.login_taken` | This login is already used by another administrator. | Ten login jest już używany przez innego administratora. | +| Login held by a deleted admin | `user.login_taken_deleted` | This login belongs to a deleted administrator. Restore that account instead of creating a new one. | Ten login należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe. | +| Email taken | `user.email_taken` | This email is already used by another administrator. | Ten adres e-mail jest już używany przez innego administratora. | +| Email held by a deleted admin | `user.email_taken_deleted` | This email belongs to a deleted administrator. Restore that account instead of creating a new one. | Ten adres e-mail należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe. | + +### Destructive actions + +| Action | Confirmation approach | Copy | +|--------|----------------------|------| +| Delete an administrator (form footer) | Existing FormView `confirm.ask({ danger: true })` with `messages.deleteConfirm` | `user.delete_confirm`; confirm button existing `form.delete` | +| Delete selected administrators (list toolbar) | Existing ListView danger confirm with the plural `messages.deleteConfirm` | `user.delete_selected_confirm` | +| Deactivate (record and bulk) | Existing confirm, not `danger` (reversible, `AdminRecordAction.Danger` false) | `user.deactivate_confirm`, `user.deactivate_selected_confirm`; the confirm button label is the action label "Deactivate" | +| Delete a role (form footer) | Existing danger confirm | `user.role.delete_confirm` | +| Delete selected roles | Existing danger confirm | `user.role.delete_selected_confirm` | +| Demote a superuser (switch off and Save) | No dialog: the save itself is the confirmation, as for every form field. The server refuses with D-09 when it would remove the last active superuser | — | + +--- + +## Component Contracts + +### C1. Settings placement (D-02) + +- Two `pact.SettingsItem` link entries in category `backend::lang.settings.category_system`, rendered by the existing `SettingsIndexView` tiles (no SPA change): + - `administrators`: label `user.menu_label`, description `user.menu_description`, icon `users`, `Controller: summercms.cabana.users`, `Permissions: [backend.manage_users]`, order 100; + - `roles`: label `user.role.menu_label`, description `user.role.menu_description`, icon `shield-check`, `Controller: summercms.cabana.roles`, `Permissions: [backend.manage_roles]`, order 110. +- Administrators comes before Roles in the group. A tile appears only when the principal can open its controller (existing filter), so an admin holding only `backend.manage_roles` sees only Roles. +- No top-level navigation item and no section flyout entry. + +### C2. Administrators list + +`config_list.yaml` (shape copied from sm-user-plugin's Users list): +- `title: user.list_title`, `recordUrl` → the record's **preview** (D-15), `recordsPerPage: 20`, `showCheckboxes: true`, `showSetup: true`, `noRecordsMessage: user.list_empty`. +- Toolbar: `buttons: [create, delete]` (create label `user.new`; delete is the soft delete), search prompt `list.search_prompt`. +- `bulkActions: [activate, deactivate, restore]`. +- Messages: `create: user.new`, `empty: user.list_empty`, `deleteConfirm: user.delete_selected_confirm`, `deleted: messages.list.deleted`, `rowStateDeleted: user.state_deleted`, `rowStateDisabled: user.state_deactivated`. No `rowStateNegative`. +- Row states (`ListRowStates`): `deleted` when `deleted_at` is set; `disabled` when not activated. A deleted and deactivated row shows both badges, in `RowStateBadges`' fixed order. + +Columns, in this order: + +| Column | Type | Visible | Searchable / sortable | Notes | +|--------|------|---------|----------------------|-------| +| `login` | text | yes | searchable, sortable | | +| `first_name` | text | yes | searchable, sortable | | +| `last_name` | text | yes | searchable, sortable | | +| `email` | text | yes | searchable, sortable | | +| `role` | relation `role`, select `name` | yes | sortable | Empty cell shows the existing `list.empty_value` dash | +| `last_login` | datetime | yes | sortable | Never signed in shows the dash | +| `is_superuser` | switch | yes, **superusers only** | — | Removed from the schema for non-superusers by the same per-request hook that removes the field and filter (D-03) | +| `created_at` | datetime | invisible | sortable | Available through list setup | + +Filters (supported types only: `switch`, `daterange`, `scope`): + +| Scope | Type | Label | Notes | +|-------|------|-------|-------| +| `role` | scope (`modelClass` role, `nameFrom: name`) | `user.role_field` | | +| `is_superuser` | switch, column `is_superuser` | `user.superuser` | Superusers only (D-03) | +| `activated` | switch, column `is_activated` | `user.activated` | | +| `last_login` | daterange, column `last_login` | `user.last_login` | | +| `trashed` | scope with two options | `user.filter_deleted` | Options `user.filter_deleted_hide` and `user.filter_deleted_only`. With no option chosen the list shows every administrator, deleted ones marked with the "Deleted" badge (the list query is unscoped, as in sm-user-plugin) | + +### C3. Administrators form + +`config_form.yaml`: `name: user.name` (en "Administrator", pl "Administrator"), `preview: { headerPartial: hint }`, `recordActions: [activate, deactivate, restore]`, redirects copied from sm-user-plugin (create → the new record's preview; update `redirect` → stays on update; `redirectClose` → preview), messages `create: user.new`, `update: messages.form.update`, `saved: messages.form.saved`, `deleteConfirm: user.delete_confirm`, `deleted: messages.form.deleted`, `edit: user.edit`, `preview: user.preview`. + +Record title (header h1, breadcrumb, `:name` in messages): "First Last" when either name is set, otherwise the login. + +Fields, by tab (two-column `FormGrid`; `left`/`right` pair on one row): + +**Tab `user.account` (the default tab)** + +| Field | Type | Span | Context | Required | Notes | +|-------|------|------|---------|----------|-------| +| `login` | text | left | create, update, preview | yes | | +| `email` | text | right | create, update, preview | yes | | +| `first_name` | text | left | all | no | | +| `last_name` | text | right | all | no | | +| `password` | password (virtual) | left | create, update | create only | Comment `user.password_comment`. Blank on update means unchanged (existing `editablePayload` rule) | +| `password_confirmation` | password (virtual) | right | create, update | create only | | +| `role` | relation belongsTo, `nameFrom: name`, `emptyOption: user.role_none` | full | all | no | Comment `user.role_comment`. Roles above the editor's ceiling render locked through `RelationLockProvider` (D-07), using `RelationField`'s existing lock icon and `form.locked_note` | +| `last_login` | datepicker (datetime) | left | **preview only** | — | Read-only display on the preview screen; never in a save body | + +**Tab `user.permissions`** + +| Field | Type | Span | Context | Notes | +|-------|------|------|---------|-------| +| `is_superuser` | switch (virtual, projected) | full | create, update, preview | Label `user.superuser`, comment `user.superuser_comment`. Present only for superusers (D-03). Renders as the existing toggle card | +| `permissions` | permissioneditor, `mode: radio` | full | create, update, preview | Label `user.permission_overrides`, comment `user.permission_overrides_comment`. `trigger: { action: disable, field: is_superuser, condition: checked }` (C6, D-06). Options locked above the editor's grants (D-07, existing `Locked`); every option record-locked on the editor's own record (C7, D-08) | + +- The tab order is fixed: Account, then Permissions. The Permissions tab is always present (a non-superuser still sees the overrides editor without the switch). +- The `FormView` footer is unchanged: Delete (danger outline, update only), Cancel (ghost), Save and close (outline), Save (primary). +- No client-side validation is added; 422 errors render under fields and in the existing banner, and the first invalid field's tab is activated (existing). + +### C4. Administrators preview (D-15) + +- Rendered by `PreviewView` unchanged. Header: back button, record title, subtitle `user.preview`, and the two tabs. +- **Status hint** (`headerPartial: hint`, server-rendered with the `.summer-callout` kit exactly as sm-user-plugin's `_hint.htm`): one callout or nothing, by precedence: + 1. deleted → `summer-callout--danger`, `user.trashed_hint_title` / `user.trashed_hint_desc`; + 2. deactivated → `summer-callout--warning`, `user.deactivated_hint_title` / `user.deactivated_hint_desc`; + 3. the record is the signed-in administrator's own → `summer-callout--warning`, `user.self_hint_title` / `user.self_hint_desc`; + 4. otherwise no callout. + The callout has `role="status"`; the hint is refetched after every record action (existing `hintKey`). +- Fields shown: Account tab `login`, `email`, `first_name`, `last_name`, `role` (relation text box), `last_login` (read-only datepicker); Permissions tab `is_superuser` (Yes/No pill, superusers only) and `permissions` (read-only editor, no lock icons on a read-only screen, existing rule). Password fields never show (existing preview rule). +- **Footer actions, left to right inside the right-aligned group**: the owner's record actions in declared order (`activate`, `deactivate`, `restore`, each only while it applies), then contributed actions (D-14) in boot merge order, then the primary **Edit administrator** button. All record actions are outline buttons (existing `RecordActions`). Applies rules: + - `activate`: not deleted and not activated; + - `deactivate`: not deleted, activated, and **not the editor's own record** (the action is not offered to yourself; the server still refuses it with `user.self_deactivate`); + - `restore`: deleted. + So a record shows at most one of Activate / Deactivate / Restore. A record action that targets the last active superuser is still offered and refused with `user.last_superuser` (the check needs the lock and is not a pure read). +- Every action asks for confirmation (existing). Success: toast with the action's success phrase, record and hint reload. 403: danger toast with the server message (C8). 409: existing stale toast and reload. +- A contributed action with `Danger: true` uses the danger confirm button; its footer button stays outline (existing rule). + +### C5. Roles list and form (D-16, D-17) + +List (`config_list.yaml`): `title: user.role.list_title`, `recordUrl` → the record's update form (Roles has **no preview block**), toolbar `[create, delete]`, `showCheckboxes: true`, no bulk actions, `noRecordsMessage: user.role.list_empty`, messages `create: user.role.new`, `deleteConfirm: user.role.delete_selected_confirm`, `deleted: messages.list.deleted`. + +| Column | Type | Visible | Searchable / sortable | +|--------|------|---------|----------------------| +| `name` | text | yes | searchable, sortable | +| `code` | text | yes | searchable, sortable | +| `description` | text | yes | searchable | +| `is_system` | switch | yes | sortable | +| `created_at` | datetime | invisible | sortable | +| `updated_at` | datetime | invisible | sortable | + +No users-count column in this phase (cabana columns have no count/`valueFrom`; D-17's refusal names the count instead). + +Form (`config_form.yaml`): `name: user.role.name` (en "Role", pl "Rola"), messages `create: user.role.new`, `update: user.role.edit`, `saved: messages.form.saved`, `deleteConfirm: user.role.delete_confirm`, `deleted: messages.form.deleted`. Create redirects to the new role's update form; Save and close returns to the list. + +| Field | Type | Span | Tab | Notes | +|-------|------|------|-----|-------| +| `name` | text | left | default (`form.tab_default`) | Required. Comment `user.role.name_comment` | +| `code` | text, `preset: { type: slug, field: name }` | right | default | Required. Comment `user.role.code_comment`. Follows the name on create until edited (existing preset rule); never follows on update. A change on a system role is refused with `user.role.code_locked` on the `code` field | +| `description` | textarea, `size: tiny` | full | default | | +| `permissions` | permissioneditor, `mode: checkbox` | full | `user.permissions` | Label `user.role.permissions_field`, comment `user.role.permissions_comment`. Code-declared grants render record-locked (C7, note `permissioneditor.locked_by_code`); permissions the editor lacks render request-locked (existing, note `permissioneditor.locked`) | + +### C6. Field `trigger` (D-21, used by D-06) + +**YAML and schema.** `trigger` becomes a valid key in `formFieldKeys`: + +```yaml +trigger: + action: disable # disable | hide + field: is_superuser # another field of the same form (same fields.yaml) + condition: checked # checked | unchecked +``` + +- Boot errors (fail fast, like every unknown key): a missing sub-key; an action other than `disable`/`hide`; a condition other than `checked`/`unchecked` (Winter's `value[...]` is not supported in this phase); a `field` that names no field of the same form, names itself, or names a field whose type is not `switch` or `checkbox`; `trigger` on a field nested in a repeater form. +- The form schema payload carries it as `trigger: { action, field, condition }` on `cabana.FormField` (swag annotation → `admin/openapi/admin.json` → `schema.d.ts`, `scripts/check-admin-openapi.sh`). + +**Evaluation (SPA).** One pure helper (suggested `admin/src/components/form/trigger.ts`) takes the field, the **whole form's** values (FormView `values`, not the active tab's scope, because the controlling field may sit on another tab) and the set of field names present in the filtered schema, and returns `'active' | 'inactive'`: +- `checked`: active when the controlling value is on by `toggleOn` (`true`, `1`, `"1"`, `"true"`); +- `unchecked`: active when it is off; +- a controlling field absent from the schema for this request (for example `is_superuser` removed for a non-superuser, D-03) or excluded by `context` counts as **off**. +It re-evaluates on every value change, immediately (no animation, no debounce). + +**`disable` while active (FormView):** +- The field renders through its own control **as read-only** (the control receives the field with `readOnly: true`, exactly as `PreviewField` does), keeping its current values visible. For `permissioneditor` this disables every segment / checkbox and, by the existing rule, hides the per-row lock icons and lock notes. +- A note line renders in `FormField` **between the label and the control** (so it is visible above a long permission editor): `
` with `Info` 14px (`shrink-0`, `aria-hidden`) and `form.trigger_disabled_on` (condition `checked`) or `form.trigger_disabled_off` (condition `unchecked`), `:field` = the controlling field's label. Its id joins the control's `aria-describedby`.
+- **Payload:** the field is left out of the save body (`editablePayload` skips it). The server then keeps the stored value: an absent `permissioneditor` key is not lifted (`liftPermissionValues` skips absent keys) and an absent scalar is not filled. Values typed before the field was disabled stay in the form and return when it is enabled again.
+- **Announcement:** when a user edit flips a trigger, the form's existing polite live region (`announce`) says "