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

@@ -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]"