feat(12.1-01): cabana.ForbiddenError answers a refused write with 403

- hooks and bulk, record, toolbar and widget actions may return it
- 403 forbidden with the localized message and field details; the write's
  transaction is rolled back; other errors stay the opaque 500
- form shows a refused save as a persistent banner and keeps the values;
  a refused delete is a toast
- smoke tests, OpenAPI notes, dist, README, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:53:34 +02:00
parent 61d5fc72ad
commit 71073bc8a2
23 changed files with 486 additions and 48 deletions

View File

@@ -737,7 +737,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
/** @description also returned when controller code refuses the write; details may name fields */
403: {
headers: {
[name: string]: unknown;
@@ -821,7 +821,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
/** @description also returned when controller code refuses the write; details may name fields */
403: {
headers: {
[name: string]: unknown;
@@ -919,7 +919,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
/** @description also returned when controller code refuses the write; details may name fields */
403: {
headers: {
[name: string]: unknown;
@@ -1709,7 +1709,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
/** @description also returned when controller code refuses the write; details may name fields */
403: {
headers: {
[name: string]: unknown;
@@ -1776,7 +1776,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
/** @description also returned when controller code refuses the write; details may name fields */
403: {
headers: {
[name: string]: unknown;
@@ -1866,7 +1866,7 @@ export interface paths {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
/** @description also returned when controller code refuses the write; details may name fields */
403: {
headers: {
[name: string]: unknown;

View File

@@ -5,8 +5,13 @@ import { t, tc } from '../../app/i18n'
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
// Messages of keys that are not form fields are listed here, so no server
// message is lost.
const props = defineProps<{ errors: Record<string, string[]>; fieldNames: string[] }>()
// message is lost. With `forbidden` (UI-SPEC S6: a save the server refused
// with 403) the same geometry shows that text instead; it stays until the
// next save attempt, and field messages still render on their fields.
const props = withDefaults(
defineProps<{ errors: Record<string, string[]>; fieldNames: string[]; forbidden?: string | null }>(),
{ forbidden: null },
)
const count = computed(() => Object.keys(props.errors).length)
const orphans = computed(() =>
@@ -18,7 +23,19 @@ const orphans = computed(() =>
<template>
<div
v-if="count > 0"
v-if="forbidden"
role="alert"
data-forbidden-banner
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
<div class="flex min-w-0 flex-col gap-1">
<p class="[overflow-wrap:anywhere]">{{ forbidden }}</p>
<p v-for="(text, index) in orphans" :key="index" class="text-[13px] [overflow-wrap:anywhere]">{{ text }}</p>
</div>
</div>
<div
v-else-if="count > 0"
role="alert"
data-error-banner
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"

View File

@@ -58,6 +58,9 @@ const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({})
const errors = ref<Record<string, string[]>>({})
// The text of a save the server refused with 403 (UI-SPEC S6). It is a
// banner, not a toast: it stays readable until the next save attempt.
const forbidden = ref<string | null>(null)
const loading = ref(true)
const failed = ref(false)
const busy = ref(false)
@@ -245,6 +248,7 @@ async function save(): Promise<RecordEnvelope | null> {
return null
}
busy.value = true
forbidden.value = null
try {
const body = editablePayload(fields.value, values.value)
const result =
@@ -265,6 +269,11 @@ async function save(): Promise<RecordEnvelope | null> {
}
if (result.response.status === 422) {
await showErrors(result.error?.error)
} else if (result.response.status === 403) {
// Nothing was saved: every entered value and the dirty state stay.
// Fields the refusal names are marked and the first is focused.
forbidden.value = result.error?.error.message || t('backend::lang.form.forbidden')
await showErrors(result.error?.error)
} else {
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
}
@@ -324,7 +333,8 @@ async function onDelete(): Promise<void> {
await go(listPath)
return
}
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
const fallback = result.response.status === 403 ? 'backend::lang.list.action_forbidden' : 'backend::lang.form.error_generic'
showToast(result.error?.error.message || t(fallback), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
@@ -403,7 +413,7 @@ void load()
</p>
<template v-else-if="schema">
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<FormErrorBanner :errors="errors" :field-names="fieldNames" :forbidden="forbidden" />
<form
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined"