feat: roles SPA Users, Profile, API keys, Activity

This commit is contained in:
Florian Sylvain committed 2026-10-28 00:10:15 +01:00
1 parent 3db490a615
commit 34dffe222f
98 files changed
+4957 -434

No files matched your search

+124 -8
View File
@@ -6,8 +6,8 @@ import router from '@/router'
import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor, customAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -85,13 +85,20 @@ interface MountOptions {
getMe?: () => Promise<Session>
handlers?: Record<string, Handler>
path?: string
width?: number
}
let mountedWrapper: VueWrapper | null = null
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
setViewportWidth(DESKTOP_WIDTH)
const {
session = MARTA,
status = 'ready',
handlers = {},
path = '/profile',
width = DESKTOP_WIDTH,
} = options
setViewportWidth(width)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = session ? { ...session } : null
@@ -304,18 +311,24 @@ describe('Default: the usual first visit with no display name', () => {
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the four cards in order Display name, Account, Author profile, Password', async () => {
it('places the cards in order Display name, Account, Your role, Author profile, Password', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
el.textContent?.trim(),
)
expect(titles).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
expect(titles).toEqual([
'Display name',
'Account',
'Your role',
'Author profile',
'Password',
])
})
})
describe('Loading: opened by URL before /auth/me has returned', () => {
it('shows the header and four skeleton cards without AppBoot, then the Default state', async () => {
it('shows the header and five skeleton cards without AppBoot, then the Default state', async () => {
const pending = deferred<Session>()
await mountApp({
session: null,
@@ -328,7 +341,8 @@ describe('Loading: opened by URL before /auth/me has returned', () => {
)
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(4)
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(5)
expect(document.body.textContent).not.toContain('Your role')
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
expect(displayNameField()).toBeNull()
@@ -905,3 +919,105 @@ describe('Reset: failed leaves the button pressable', () => {
},
)
})
const MARTA_ASSIGNER = { id: 3, name: 'Marta Alves' }
const KEYS_SENTENCE = 'Your API keys follow this role. If your role changes, they change too.'
function contributor(assigner?: { id: number; name: string } | null): Session {
const access = customAccess(
'content.read=all, content.create, content.update=own',
'Contributor',
)
return {
...MARTA,
access: assigner === undefined ? access : { ...access, roleAssignedBy: assigner },
}
}
function pageText() {
return document.body.textContent ?? ''
}
function roleRequests(request: ReturnType<typeof vi.fn>) {
return request.mock.calls.filter(([path]) => String(path).startsWith('/v1/role'))
}
describe('Profile: your role', () => {
it('shows the role card with the badge, the assigner and the keys sentence', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
expect(pageText()).toContain('Your role')
expect(pageText()).toContain('Contributor')
expect(pageText()).toContain('Assigned by Marta Alves')
expect(pageText()).toContain(KEYS_SENTENCE)
expect(buttonByText(/^See what I can do$/)).toBeDefined()
})
it.each([
['null', null],
['an absent key', undefined],
])('renders no Assigned by text for %s', async (_label, assigner) => {
await mountApp({ session: contributor(assigner) })
expect(pageText()).toContain('Your role')
expect(pageText()).not.toContain('Assigned by')
})
it('places the card after the Account card', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
const titles = [...document.body.querySelectorAll('h2')].map((h2) => h2.textContent?.trim())
expect(titles.indexOf('Your role')).toBe(titles.indexOf('Account') + 1)
})
it('follows a refreshed session for the assigner and the role', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
useSessionStore().session = {
...contributor({ id: 2, name: 'Jonas Weber' }),
}
await flushPromises()
expect(pageText()).toContain('Assigned by Jonas Weber')
expect(pageText()).not.toContain('Assigned by Marta Alves')
const writer = customAccess('content.read=all', 'Writer')
useSessionStore().session = { ...MARTA, access: writer }
await flushPromises()
expect(pageText()).toContain('Writer')
expect(pageText()).not.toContain('Assigned by')
})
it('opens the summary dialog from the session grants without reading roles, and Close returns focus', async () => {
const { request } = await mountApp({ session: contributor(MARTA_ASSIGNER) })
const opener = buttonByText(/^See what I can do$/)!
opener.focus()
opener.click()
await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('What you can do')
expect(dialog.textContent).toContain('A summary of your role, Contributor. Read-only.')
expect(dialog.textContent).not.toContain('Updates as you edit.')
expect(dialog.textContent).toContain('Content')
expect(roleRequests(request)).toHaveLength(0)
const close = [...dialog.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Close',
)!
close.click()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(document.activeElement).toBe(opener)
})
it('opens the summary as a bottom sheet on mobile', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER), width: MOBILE_WIDTH })
buttonByText(/^See what I can do$/)!.click()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'What you can do',
)
})
})