feat: roles SPA foundations

This commit is contained in:
Florian Sylvain committed 2026-10-27 22:21:12 +01:00
1 parent 80f7037d3a
commit 907949aa12
180 files changed
+4308 -835

No files matched your search

@@ -0,0 +1,147 @@
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, type SessionStatus } from '@/stores/session'
import { customAccess, noAccess } from '@/test/access'
import { setLocale } from '@/i18n'
let wrapper: VueWrapper | null = null
interface Setup {
grants: string
role?: string
path: string
status?: SessionStatus
verified?: boolean
}
async function open({
grants,
role = 'Contributor',
path,
status = 'ready',
verified = true,
}: Setup) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { id: 4, verified, displayName: '', access: customAccess(grants, role) }
session.status = status
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
setLocale('en')
document.body.innerHTML = ''
await router.push('/login')
})
const NO_ACCESS_TITLE = "You don't have access to this page"
describe('A direct link to a page the role lacks', () => {
it.each([
['content.read=all', 'Contributor', '/schema', 'Go to Articles'],
['content.read=all', 'Contributor', '/schema/new', 'Go to Articles'],
['content.read=all', 'Contributor', '/users', 'Go to Articles'],
['content.read=all', 'Contributor', '/settings/sites', 'Go to Articles'],
['content.read=all', 'Contributor', '/integration/api-keys', 'Go to Articles'],
[
'content.read=all, content.read:events=none',
'Contributor',
'/content/events',
'Go to Articles',
],
['media.view', 'Media', '/schema', 'Go to Home'],
])(
'%s opening %s shows the page-level 403 and keeps the URL',
async (grants, role, path, action) => {
const view = await open({ grants, role, path })
expect(router.currentRoute.value.fullPath).toBe(path)
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).toContain(NO_ACCESS_TITLE)
expect(view.text()).toContain(
`Your role, ${role}, doesn't include it. Ask an owner or admin if you need it.`,
)
const buttons = view
.findAll('[data-slot="root"] button')
.filter((button) => !button.element.closest('nav'))
expect(buttons.map((button) => button.text()).filter((label) => label !== '')).toEqual([
action,
])
},
)
})
describe('A page the role can use', () => {
it.each([
['schema.view', '/schema'],
['schema.edit', '/schema/new'],
['users.view', '/users'],
['settings.manage_sites', '/settings/sites'],
['content.read:events=own', '/content/events'],
['content.create:events=all', '/content/events/new'],
['apikeys.view=own', '/integration/api-keys'],
['settings.view', '/settings/media/cache'],
])('%s opens %s without a 403', async (grants, path) => {
const view = await open({ grants, path })
expect(view.find('[data-testid="page"]').exists()).toBe(true)
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
})
})
describe('The guard', () => {
it('shows neither the page nor the 403 while the session is checking', async () => {
const view = await open({ grants: 'media.view', path: '/schema', status: 'checking' })
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
useSessionStore().status = 'ready'
await flushPromises()
expect(view.text()).toContain(NO_ACCESS_TITLE)
})
it('leaves an unverified session to the pending redirect', async () => {
const view = await open({ grants: '', path: '/schema', verified: false })
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
})
it('switches to the 403 state when grants are lowered while the page is open', async () => {
const view = await open({ grants: 'schema.view', path: '/schema' })
expect(view.find('[data-testid="page"]').exists()).toBe(true)
const session = useSessionStore()
session.session = { ...session.session!, access: noAccess('Contributor') }
await flushPromises()
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).toContain(NO_ACCESS_TITLE)
expect(router.currentRoute.value.fullPath).toBe('/schema')
})
it('shows the gate copy in French', async () => {
setLocale('fr')
const view = await open({ grants: 'content.read=all', path: '/users' })
expect(view.text()).toContain('Vous n’avez pas accès à cette page')
})
})
+10 -5
View File
@@ -6,6 +6,7 @@ import { defineComponent, h } from 'vue'
import { RouterView } from 'vue-router'
import router from '@/router'
import { useApi, ApiError } from '@/composables/useApi'
import { ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -86,7 +87,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
})
it('A second navigation reuses the cached session instead of calling GET /v1/auth/me again', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -102,7 +103,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
})
it('A verified-only route admits a verified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -341,7 +342,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
])(
'Placeholder verified route $path renders inside DashboardShell with title $title',
async ({ path, title }) => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -360,9 +361,13 @@ describe('Nuxt UI shell foundation and router guards', () => {
it('The /media route is verified, titled Media and renders MediaView inside DashboardShell', async () => {
const route = router.getRoutes().find((candidate) => candidate.path === '/media')
expect(route?.meta).toEqual({ auth: 'verified', title: 'Media' })
expect(route?.meta).toEqual({
auth: 'verified',
title: 'Media',
access: { key: 'media.view' },
})
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
const request = vi.fn().mockResolvedValue({
items: [],
total: 0,
+108 -36
View File
@@ -1,3 +1,4 @@
import { defineComponent, h, type Component } from 'vue'
import {
createRouter,
createWebHistory,
@@ -8,9 +9,34 @@ import {
} from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { installDocumentTitle } from '@/composables/documentTitle'
import { ARTICLE_TYPE_KEY, type RouteAccess } 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 }>) {
return () => {
let resolved = gatedViews.get(key)
if (resolved === undefined) {
resolved = Promise.all([load(), import('@/components/RouteAccessGate.vue')]).then(
([view, gate]) =>
defineComponent({
name: 'GatedRoute',
setup: () => () =>
h(gate.default, null, { default: () => h(view.default) }),
}),
)
gatedViews.set(key, resolved)
}
return resolved
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
@@ -59,70 +85,116 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/article',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
meta: {
auth: 'verified',
title: 'Articles',
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
},
},
{
path: '/article/new',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
meta: {
auth: 'verified',
title: 'Articles',
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
},
},
{
path: '/article/:id/edit',
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
component: gated('@/views/EditorView.vue', () => import('@/views/EditorView.vue')),
meta: {
auth: 'verified',
title: 'Editor',
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
},
},
{
path: '/media',
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
component: gated('@/views/MediaView.vue', () => import('@/views/MediaView.vue')),
meta: { auth: 'verified', title: 'Media', access: { key: 'media.view' } },
},
{
path: '/authors',
component: () => import('@/views/AuthorsView.vue'),
meta: { auth: 'verified', title: 'Authors' },
component: gated('@/views/AuthorsView.vue', () => import('@/views/AuthorsView.vue')),
meta: { auth: 'verified', title: 'Authors', access: { key: 'authors.view' } },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
component: gated('@/views/UsersView.vue', () => import('@/views/UsersView.vue')),
meta: { auth: 'verified', title: 'Users', access: { key: 'users.view' } },
},
{
path: '/schema',
component: () => import('@/views/SchemaView.vue'),
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
component: gated('@/views/SchemaView.vue', () => import('@/views/SchemaView.vue')),
meta: {
auth: 'verified',
title: 'Schema',
needsBuiltinTypes: true,
access: { key: 'schema.view' },
},
},
{
path: '/schema/new',
component: () => import('@/views/SchemaBuilderView.vue'),
meta: { auth: 'verified', title: 'Schema' },
component: gated(
'@/views/SchemaBuilderView.vue',
() => import('@/views/SchemaBuilderView.vue'),
),
meta: { auth: 'verified', title: 'Schema', access: { key: 'schema.edit' } },
},
{
path: '/schema/:key',
component: () => import('@/views/SchemaTypeView.vue'),
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
component: gated('@/views/SchemaTypeView.vue', () => import('@/views/SchemaTypeView.vue')),
meta: {
auth: 'verified',
title: 'Schema',
needsBuiltinTypes: true,
access: { key: 'schema.view' },
},
},
{
path: '/content/:key',
component: () => import('@/views/ContentListView.vue'),
meta: { auth: 'verified', title: 'Content' },
component: gated(
'@/views/ContentListView.vue',
() => import('@/views/ContentListView.vue'),
),
meta: {
auth: 'verified',
title: 'Content',
access: { key: 'content.read', typeParam: 'key' },
},
},
{
path: '/content/:key/new',
name: 'entry-new',
component: () => import('@/views/EntryEditorView.vue'),
meta: { auth: 'verified', title: 'Content' },
component: gated(
'@/views/EntryEditorView.vue',
() => import('@/views/EntryEditorView.vue'),
),
meta: {
auth: 'verified',
title: 'Content',
access: { key: 'content.create', typeParam: 'key' },
},
},
{
path: '/content/:key/:id',
name: 'entry-edit',
component: () => import('@/views/EntryEditorView.vue'),
meta: { auth: 'verified', title: 'Content' },
component: gated(
'@/views/EntryEditorView.vue',
() => import('@/views/EntryEditorView.vue'),
),
meta: {
auth: 'verified',
title: 'Content',
access: { key: 'content.read', typeParam: 'key' },
},
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
meta: { auth: 'verified', title: 'Activity' },
component: gated('@/views/ActivityView.vue', () => import('@/views/ActivityView.vue')),
meta: { auth: 'verified', title: 'Activity', access: { key: 'activity.view' } },
},
{
path: '/profile',
@@ -136,28 +208,28 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/integration/api-keys',
component: () => import('@/views/ApiKeysView.vue'),
meta: { auth: 'verified', title: 'Integration' },
component: gated('@/views/ApiKeysView.vue', () => import('@/views/ApiKeysView.vue')),
meta: { auth: 'verified', title: 'Integration', access: { key: 'apikeys.view' } },
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/SettingsView.vue', () => import('@/views/SettingsView.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
},
{
path: '/settings/media/variants',
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/VariantsPage.vue', () => import('@/views/VariantsPage.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
},
{
path: '/settings/sites',
component: () => import('@/views/SitesView.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
},
{
path: '/settings/media/cache',
component: () => import('@/views/CachePage.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
},
{
path: '/:pathMatch(.*)*',