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:
@@ -2662,7 +2662,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
"description": "also returned when controller code refuses the write; details may name fields"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
@@ -2767,7 +2767,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
"description": "also returned when controller code refuses the write; details may name fields"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
@@ -2892,7 +2892,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
"description": "also returned when controller code refuses the write; details may name fields"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
@@ -3820,7 +3820,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
"description": "also returned when controller code refuses the write; details may name fields"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
@@ -4032,7 +4032,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
"description": "also returned when controller code refuses the write; details may name fields"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
@@ -4156,7 +4156,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
"description": "also returned when controller code refuses the write; details may name fields"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
|
||||
12
admin/src/api/schema.d.ts
vendored
12
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
|
||||
// (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the
|
||||
// row state badges (UI-SPEC S4, D-12).
|
||||
// row state badges (UI-SPEC S4, D-12) and the forbidden save banner (UI-SPEC
|
||||
// S6, D-27).
|
||||
// Fixtures are neutral acme.roster.* data; no application
|
||||
// names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -9,7 +10,15 @@ import { setBundle } from '../../src/app/i18n'
|
||||
import RecordActions from '../../src/components/form/RecordActions.vue'
|
||||
import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
|
||||
import ToastHost from '../../src/components/ui/Toast.vue'
|
||||
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import {
|
||||
clone,
|
||||
formSchemaFixture,
|
||||
langFixture,
|
||||
recordFixture,
|
||||
rosterListFixture,
|
||||
rosterListSchemaFixture,
|
||||
rosterRecordFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
@@ -32,6 +41,7 @@ const strings = {
|
||||
'backend::lang.form.action_stale': {
|
||||
other: 'This action no longer applies to this record. The page has been refreshed.',
|
||||
},
|
||||
'backend::lang.form.forbidden': { other: 'You do not have permission to make this change. Nothing was saved.' },
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
@@ -545,3 +555,131 @@ describe('row state (UI-SPEC S4, D-12)', () => {
|
||||
expect(none.text()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forbidden save (UI-SPEC S6, D-27)', () => {
|
||||
const WIDGETS = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${WIDGETS}/1`
|
||||
|
||||
function formRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${WIDGETS}/schema/form`]: { body: formSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: recordFixture },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
|
||||
status: 403,
|
||||
body: { error: { code: 'forbidden', message, details } },
|
||||
})
|
||||
|
||||
const banner = (wrapper: VueWrapper) => wrapper.find('[data-forbidden-banner]')
|
||||
const nameInput = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('#field-name')
|
||||
|
||||
it('shows a persistent alert banner with the server message, keeps the values and marks the named field', async () => {
|
||||
const long = 'You may not rename this widget because it belongs to a maker you cannot manage. '.repeat(3).trim()
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
formRoutes({ [`PUT ${RECORD}`]: refused(long, { name: ['This name is reserved.'] }) }),
|
||||
{ attach: true },
|
||||
)
|
||||
await nameInput(wrapper).setValue('Reserved')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(banner(wrapper).attributes('role')).toBe('alert')
|
||||
expect(banner(wrapper).text()).toBe(long)
|
||||
expect(banner(wrapper).find('svg').exists()).toBe(true)
|
||||
// A long message wraps; it is never truncated.
|
||||
expect(banner(wrapper).html()).not.toContain('truncate')
|
||||
// It is a banner, not a toast, and the 422 banner does not show.
|
||||
expect(wrapper.find('[data-tone]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
||||
// Nothing was saved: the typed value and the dirty state stay.
|
||||
expect(nameInput(wrapper).element.value).toBe('Reserved')
|
||||
expect(wrapper.text()).toContain('This name is reserved.')
|
||||
expect(nameInput(wrapper).attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement).toBe(nameInput(wrapper).element)
|
||||
|
||||
// Leaving still asks: the form is dirty.
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
await press('cancel')
|
||||
expect(banner(wrapper).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the framework text when the server sends no message, without field marks', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes({ [`PUT ${RECORD}`]: refused('') }), {
|
||||
attach: true,
|
||||
})
|
||||
await nameInput(wrapper).setValue('Other')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(banner(wrapper).text()).toBe('You do not have permission to make this change. Nothing was saved.')
|
||||
expect(nameInput(wrapper).attributes('aria-invalid')).not.toBe('true')
|
||||
expect(nameInput(wrapper).element.value).toBe('Other')
|
||||
})
|
||||
|
||||
it('refuses a create the same way', async () => {
|
||||
const { wrapper, router } = await mountApp(
|
||||
'/acme/demo/widgets/create',
|
||||
formRoutes({ [`POST ${WIDGETS}`]: refused('You may not add widgets here.') }),
|
||||
{ attach: true },
|
||||
)
|
||||
await nameInput(wrapper).setValue('New widget')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(banner(wrapper).text()).toBe('You may not add widgets here.')
|
||||
expect(nameInput(wrapper).element.value).toBe('New widget')
|
||||
expect(router.currentRoute.value.name).toBe('create')
|
||||
})
|
||||
|
||||
it('clears the banner when the next save attempt starts', async () => {
|
||||
let attempt = 0
|
||||
let release: ((reply: Reply) => void) | undefined
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
formRoutes({
|
||||
[`PUT ${RECORD}`]: () => {
|
||||
attempt += 1
|
||||
if (attempt === 1) {
|
||||
return refused('You may not rename this widget.')
|
||||
}
|
||||
return new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await nameInput(wrapper).setValue('Reserved')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(banner(wrapper).exists()).toBe(true)
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
// The second request is still running; the banner is already gone.
|
||||
expect(release).toBeDefined()
|
||||
expect(banner(wrapper).exists()).toBe(false)
|
||||
release?.({ body: recordFixture })
|
||||
await flushPromises()
|
||||
expect(banner(wrapper).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts a refused delete with the server message or the fallback', async () => {
|
||||
const { wrapper, router } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
formRoutes({ [`DELETE ${RECORD}`]: refused('') }),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
await press('confirm')
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('You do not have permission to run this action.')
|
||||
expect(banner(wrapper).exists()).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('record')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,7 +192,9 @@ describe('edit a record', () => {
|
||||
it('shows a danger toast with the envelope message for other errors', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', {
|
||||
...formRoutes,
|
||||
[`PUT ${RECORD}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden.', details: {} } } },
|
||||
// Not a 422 and not a 403: a refused save (403) is the forbidden banner
|
||||
// (Phase 12.1, UI-SPEC S6), covered in actions.smoke.test.ts.
|
||||
[`PUT ${RECORD}`]: { status: 409, body: { error: { code: 'conflict', message: 'The record changed.', details: {} } } },
|
||||
})
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
@@ -200,8 +202,9 @@ describe('edit a record', () => {
|
||||
|
||||
const toast = wrapper.find('[data-tone="danger"]')
|
||||
expect(toast.attributes('role')).toBe('alert')
|
||||
expect(toast.text()).toContain('Forbidden.')
|
||||
expect(toast.text()).toContain('The record changed.')
|
||||
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-forbidden-banner]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders an unknown field type as the unsupported box instead of breaking the form', async () => {
|
||||
|
||||
Reference in New Issue
Block a user