feat: SPA Roles screens

This commit is contained in:
Florian Sylvain committed 2026-10-27 23:05:04 +01:00
1 parent 907949aa12
commit 3db490a615
47 files changed
+6947 -9

No files matched your search

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import RouteAccessGate from '@/components/RouteAccessGate.vue'
import { useSessionStore } from '@/stores/session'
import { customAccess } from '@/test/access'
let wrapper: VueWrapper | null = null
async function open(grants: string, path: string, role = 'Contributor') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { id: 4, verified: true, displayName: '', access: customAccess(grants, role) }
session.status = 'ready'
await router.push(path).catch(() => undefined)
wrapper = mount(
() =>
h(UApp, null, () =>
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' }, 'PAGE')),
),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
return wrapper
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Route access', () => {
it.each([
['roles.view', '/settings/roles', true],
['roles.view', '/settings/roles/new', false],
['roles.view, roles.manage', '/settings/roles/new', true],
['roles.view', '/settings/roles/4', true],
['content.read=all', '/settings/roles', false],
['content.read=all', '/settings/roles/4', false],
])('%s opening %s renders the page: %s', async (grants, path, allowed) => {
const view = await open(grants, path)
expect(router.currentRoute.value.fullPath).toBe(path)
expect(view.find('[data-testid="page"]').exists()).toBe(allowed)
if (!allowed) {
expect(view.text()).toContain("You don't have access to this page")
}
})
it('shows the page-level 403 with Go to Articles to a Contributor', async () => {
const view = await open('content.read=all', '/settings/roles')
expect(view.text()).toContain(
"Your role, Contributor, doesn't include it. Ask an owner or admin if you need it.",
)
expect(view.text()).toContain('Go to Articles')
})
})
describe('/settings/permissions is retired', () => {
it('replaces the URL with /settings/roles and flashes no 403', async () => {
const view = await open('roles.view', '/settings/permissions')
expect(router.currentRoute.value.fullPath).toBe('/settings/roles')
expect(view.find('[data-testid="page"]').exists()).toBe(true)
expect(view.text()).not.toContain("You don't have access to this page")
})
it('redirects even for someone who cannot see roles, to the same 403 as /settings/roles', async () => {
await open('content.read=all', '/settings/permissions')
expect(router.currentRoute.value.fullPath).toBe('/settings/roles')
})
})
describe('the session is still being checked', () => {
it('keeps the boot screen instead of the 403 state', async () => {
setActivePinia(createPinia())
const session = useSessionStore()
session.status = 'checking'
await router.push('/settings/roles').catch(() => undefined)
wrapper = mount(
() =>
h(UApp, null, () =>
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' })),
),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
expect(wrapper.text()).not.toContain("You don't have access to this page")
})
})
+20 -5
View File
@@ -9,14 +9,10 @@ import {
} from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { installDocumentTitle } from '@/composables/documentTitle'
import { ARTICLE_TYPE_KEY, type RouteAccess } from '@/lib/routeAccess'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
export function routeAccessOf(route: RouteLocationNormalized): RouteAccess | undefined {
return route.meta.access as RouteAccess | undefined
}
const gatedViews = new Map<string, Promise<Component>>()
function gated(key: string, load: () => Promise<{ default: Component }>) {
@@ -226,6 +222,25 @@ const routes: RouteRecordRaw[] = [
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
},
{
path: '/settings/permissions',
redirect: '/settings/roles',
},
{
path: '/settings/roles',
component: gated('@/views/RolesListView.vue', () => import('@/views/RolesListView.vue')),
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.view' } },
},
{
path: '/settings/roles/new',
component: gated('@/views/RoleEditorView.vue', () => import('@/views/RoleEditorView.vue')),
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.manage' } },
},
{
path: '/settings/roles/:id',
component: gated('@/views/RoleEditorView.vue', () => import('@/views/RoleEditorView.vue')),
meta: { auth: 'verified', title: 'Roles', access: { key: 'roles.view' } },
},
{
path: '/settings/media/cache',
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),