fix(admin): shadow-DOM-aware plain keys, commit drafts before shortcut saves and safe My account links (B-WR-01, B-WR-02, B-WR-03, B-WR-06, B-IN-01)

- plain-key shortcuts test composedPath()[0], and a custom element inside a
  form counts as editable (closed shadow roots)
- Ctrl/Cmd+S and Ctrl/Cmd+Enter blur the focused field before clicking, so a
  pending taglist draft or native change is committed, then refocus it
- safeUrl keeps javascript:/data: URLs out of href and window.open
- a failed contributions GET shows a toast and keeps the sections; a section
  with error true shows an error line
- My account confirm and stale texts get their own phrase keys
- rebuilt modules/boardwalk/dist
This commit is contained in:
Jakub Zych
2026-10-23 16:29:10 +02:00
parent 44835e4c6b
commit 88559d5731
11 changed files with 92 additions and 24 deletions

View File

@@ -96,12 +96,31 @@ export function comboOf(event: KeyboardEvent): HotkeyCombo | null {
}
}
/** True when the target is inside an input, textarea, select or contenteditable element. */
/**
* True when the target is inside an input, textarea, select or contenteditable
* element, or is a custom element (a tag name with "-") inside a form: a
* plugin field type whose closed shadow root hides the real input, where
* composedPath() stops at the host.
*/
export function isEditableTarget(target: EventTarget | null): boolean {
if (!target || typeof (target as Element).closest !== 'function') {
return false
}
return (target as Element).closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null
const element = target as Element
if (element.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null) {
return true
}
return element.tagName.includes('-') && element.closest('form, [role="form"]') !== null
}
/**
* Where a key event really started: the first entry of composedPath(), which
* reaches inside an open shadow root (event.target is retargeted to the
* shadow host by the time the event reaches window), else event.target.
*/
export function originOf(event: Event): EventTarget | null {
const path = typeof event.composedPath === 'function' ? event.composedPath() : []
return path[0] ?? event.target
}
/** The keys of a shortcut as shown on screen, one chip each. */
@@ -202,7 +221,16 @@ function onModifierKey(event: KeyboardEvent): void {
if (event.repeat || isDisabled(target)) {
return
}
// Commit pending edits the way a pointer click would: blurring the
// focused field runs its blur and native change handlers (a taglist's
// typed-but-uncommitted tag) before the button reads the values.
const active = doc.activeElement
const blurred = active instanceof HTMLElement && active !== target && active !== doc.body ? active : null
blurred?.blur()
target.click()
if (blurred?.isConnected) {
blurred.focus()
}
}
function onPlainKey(event: KeyboardEvent): void {
@@ -210,7 +238,7 @@ function onPlainKey(event: KeyboardEvent): void {
if (combo !== 'escape' && combo !== '/' && combo !== '?') {
return
}
if (event.defaultPrevented || isEditableTarget(event.target)) {
if (event.defaultPrevented || isEditableTarget(originOf(event))) {
return
}
const doc = (event.target as Node | null)?.ownerDocument ?? document

View File

@@ -22,12 +22,33 @@ const running = ref('')
/** The URL the last action answered, per section, kept as a visible link. */
const links = ref<Record<string, string>>({})
/**
* The URL when it may be rendered and opened: an absolute http(s) URL or a
* same-origin path (one leading "/"), else ''. The server applies the same
* rule; this keeps a javascript: or data: URL out of href and window.open
* even if one gets through.
*/
function safeUrl(u: string): string {
if (/^https?:\/\//i.test(u)) {
return u
}
if (u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')) {
return u
}
return ''
}
async function load(): Promise<void> {
try {
const result = await api.GET('/account/contributions')
sections.value = result.data?.data ?? []
if (result.data) {
sections.value = result.data.data ?? []
return
}
// A failed load keeps the sections already shown and says so.
showToast(t('backend::lang.form.error_generic'), 'danger')
} catch {
sections.value = []
showToast(t('backend::lang.form.error_generic'), 'danger')
}
}
@@ -40,16 +61,17 @@ async function post(section: AccountContribution, action: AccountContributionAct
if (result.data) {
const answer = result.data.data
showToast(answer.message || t('backend::lang.form.action_done'))
if (answer.url) {
links.value = { ...links.value, [section.name]: answer.url }
window.open(answer.url, '_blank', 'noopener')
const url = safeUrl(answer.url ?? '')
if (url) {
links.value = { ...links.value, [section.name]: url }
window.open(url, '_blank', 'noopener')
}
await load()
return
}
const status = result.response.status
if (status === 409) {
showToast(t('backend::lang.form.action_stale'), 'danger')
showToast(t('backend::lang.myaccount.action_stale'), 'danger')
await load()
return
}
@@ -75,7 +97,7 @@ async function run(section: AccountContribution, action: AccountContributionActi
}
await props.confirm.ask(
{
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
message: action.confirm || t('backend::lang.myaccount.action_confirm', { action: action.label }),
confirmLabel: action.label,
danger: action.danger,
},
@@ -100,10 +122,13 @@ onMounted(() => {
<p v-if="section.description" class="max-w-[640px] text-[13px] text-muted">{{ section.description }}</p>
</div>
<p v-if="section.status" data-account-contribution-status class="font-medium">{{ section.status }}</p>
<p v-if="section.error" data-account-contribution-error class="text-[13px] text-muted">
{{ t('backend::lang.myaccount.section_error') }}
</p>
<a
v-if="links[section.name]"
v-if="safeUrl(links[section.name] ?? '')"
data-account-contribution-url
:href="links[section.name]"
:href="safeUrl(links[section.name] ?? '')"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline [overflow-wrap:anywhere]"

View File

@@ -12,7 +12,7 @@ const CONTRIBUTIONS = `${API}/account/contributions`
const OPEN = `${CONTRIBUTIONS}/site_link/actions/open`
const strings = {
'backend::lang.form.action_stale': { other: 'This action no longer applies.' },
'backend::lang.myaccount.action_stale': { other: 'This action no longer applies.' },
'backend::lang.form.action_done': { other: 'Action completed.' },
}

View File

@@ -513,6 +513,12 @@
"many": "Odłączono :count rekordów",
"other": "Odłączono :count rekordu"
},
"backend::lang.myaccount.action_confirm": {
"other": "Wykonać „:action”?"
},
"backend::lang.myaccount.action_stale": {
"other": "Ta akcja nie ma już zastosowania. Strona została odświeżona."
},
"backend::lang.myaccount.avatar": {
"other": "Awatar"
},
@@ -585,6 +591,9 @@
"backend::lang.myaccount.saved": {
"other": "Zapisano konto"
},
"backend::lang.myaccount.section_error": {
"other": "Nie udało się wczytać tej sekcji."
},
"backend::lang.myaccount.subtitle": {
"other": "Podtytuł"
},

View File

@@ -147,7 +147,7 @@ Every signed-in administrator can edit their own profile on the My account scree
- **Preferences:** the theme (system, light or dark) and the admin language an administrator picks in the SPA are saved through `PUT <prefix>/api/v1/account/preferences` and read with `GET <prefix>/api/v1/account/preferences`; `GET .../auth/me` returns them as `preferences`. The body may hold only `theme` and `locale` (`cabana.AdminPreferencesRequest`); any other key, including `id`, answers 422 on that key and nothing is written. Each present key is saved and an absent key keeps its value, so `{}` answers the current preferences. `theme` must be `system`, `light` or `dark`; `locale` must be `""` (follow the request locale) or one of the admin locales `GET /lang` lists in `meta.locales`; another value or a non-string answers 422 on that field. They are stored under `admin_theme` and `admin_locale` in `backend_users.metadata`, next to the subtitle; the defaults (`system`, `""`) are stored as absent keys, every other key survives a save, and metadata that is not a JSON object is never overwritten (422). Like the rest of My account the routes act only on the token's own administrator and refuse personal API tokens.
- **API tokens:** a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (`30d`, `90d`, `365d` or `never`) and scopes from `GET /account/tokens/scopes`; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See [API tokens](#api-tokens) for the wire contract and scopes.
- **Plugin sections:** a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements `pact.HasAccountContributions` and returns `pact.AccountContribution` entries: a name, a label and description, an optional status line computed for the administrator, and buttons (`pact.AccountAction`) with an optional confirmation (danger variant included), extra permissions and an `Applies` rule. The SPA loads them from `GET <prefix>/api/v1/account/contributions` and runs a button through `POST <prefix>/api/v1/account/contributions/{name}/actions/{action}`, which calls the action's `Run` with the signed-in administrator's id inside a transaction (`cabana.TxFromContext`) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Only an absolute `http`/`https` URL or a same-origin path is passed on; the admin drops anything else. A section whose status fails to load is still listed, marked `error: true`, and the other sections are unaffected. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged.
- **Plugin sections:** a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements `pact.HasAccountContributions` and returns `pact.AccountContribution` entries: a name, a label and description, an optional status line computed for the administrator, and buttons (`pact.AccountAction`) with an optional confirmation (danger variant included), extra permissions and an `Applies` rule. The SPA loads them from `GET <prefix>/api/v1/account/contributions` and runs a button through `POST <prefix>/api/v1/account/contributions/{name}/actions/{action}`, which calls the action's `Run` with the signed-in administrator's id inside a transaction (`cabana.TxFromContext`) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Only an absolute `http`/`https` URL or a same-origin path is passed on; the admin drops anything else. A section whose status fails to load is still listed, marked `error: true`, and My account shows it with an error line; the other sections are unaffected. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged.
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BkkTf5-Z.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C8x99JB6.css">
<script type="module" crossorigin src="./assets/index-D_tymZt6.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Bakgr9jB.css">
</head>
<body>
<div id="app"></div>

View File

@@ -372,6 +372,9 @@ myaccount:
avatar_hint: JPG, PNG, GIF or WebP, up to 2 MB.
avatar_saved: Avatar saved
avatar_removed: Avatar removed
action_confirm: "Run “:action”?"
action_stale: This action no longer applies. The page has been refreshed.
section_error: This section could not be loaded.
richeditor:
removed_elements:
one: ":count element was removed: :names"

View File

@@ -394,6 +394,9 @@ myaccount:
avatar_hint: JPG, PNG, GIF lub WebP, maksymalnie 2 MB.
avatar_saved: Zapisano awatar
avatar_removed: Usunięto awatar
action_confirm: "Wykonać „:action”?"
action_stale: Ta akcja nie ma już zastosowania. Strona została odświeżona.
section_error: Nie udało się wczytać tej sekcji.
richeditor:
removed_elements:
one: "Usunięto :count element: :names"