feat(authentik): add application access editor and paged permission matrix

Provide user, group and QQQ application-role permission editing with searchable,
paged controls and inherited-source visibility. Reuse the matrix in policy
editing and preserve form context for process widgets.

Co-Authored-By: GPT-6 <noreply@openai.com>
This commit is contained in:
james.maesandGPT-6 committed 2026-11-05 05:23:11 -06:00
1 parent d04cfa4b58
commit 119f1211d0
9 files changed
+434 -6

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
@@ -0,0 +1,124 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Access editor behavior: stable selections, grant sources and API failures. */
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, it, expect, vi } from 'vitest'
import type { QWidgetMetaData } from '@/types'
import { fetchWidgetData } from '@/lib/api/widgets'
import { fetchPossibleValues } from '@/lib/api/possible-values'
import AuthentikAccessEditorWidget from './AuthentikAccessEditorWidget'
vi.mock('@/lib/api/widgets', () => ({ fetchWidgetData: vi.fn() }))
vi.mock('@/lib/api/possible-values', () => ({ fetchPossibleValues: vi.fn() }))
const widget = { name: 'access', type: 'AUTHENTIK_ACCESS_EDITOR' } as QWidgetMetaData
/**
* Render with an isolated query cache.
* @param fields - Editor fields.
* @returns Process value callback.
*/
function setup(fields: Record<string, unknown>[]) {
const changed = vi.fn()
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<AuthentikAccessEditorWidget widgetMetaData={widget} data={{ fields }} onWidgetData={changed} />
</QueryClientProvider>)
return changed
}
describe('Access editor', () => {
it('preserves unknown grants and displays inherited access without making it editable', async () => {
const changed = setup([{ name: 'permissions', label: 'Direct permissions', selected: [{ id: 'legacy.read', label: 'legacy.read' }],
choices: [{ id: 'posts.edit', label: 'Edit posts' }], inherited: { 'posts.read': ['Readers'] } }])
expect(screen.getByLabelText('legacy.read')).toBeChecked()
await userEvent.click(screen.getByLabelText('Edit posts'))
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\nposts.edit' })
expect(screen.queryByRole('checkbox', { name: /posts.read/ })).not.toBeInTheDocument()
expect(screen.getByText('posts.read — Readers')).toBeInTheDocument()
})
it('searches the server and preserves selected IDs when results change', async () => {
vi.mocked(fetchPossibleValues).mockImplementation(async (_source, request) => request?.searchTerm === 'alex'
? [{ id: 42, label: 'Alex' }] : [{ id: 3, label: 'Sam' }])
const changed = setup([{ name: 'userIds', label: 'Users', source: 'identityUsers', selected: [] }])
await userEvent.click(await screen.findByLabelText('Sam'))
await userEvent.type(screen.getByLabelText('Search users'), 'alex')
await userEvent.click(await screen.findByLabelText('Alex'))
expect(screen.getByLabelText('Sam')).toBeChecked()
expect(changed).toHaveBeenLastCalledWith({ userIds: '3\n42' })
expect(fetchPossibleValues).toHaveBeenLastCalledWith('identityUsers', { searchTerm: 'alex', useCase: 'form' })
})
it('keeps existing assignments when the directory search fails', async () => {
vi.mocked(fetchPossibleValues).mockRejectedValue(new Error('Offline'))
const changed = setup([{ name: 'parentIds', label: 'Roles', source: 'roles', selected: [{ id: 'role-1', label: 'Reader' }] }])
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not load choices'))
expect(screen.getByLabelText('Reader')).toBeChecked()
expect(changed).toHaveBeenCalledWith({ parentIds: 'role-1' })
})
it('rejects malformed payloads without submitting an empty permission list', () => {
const changed = setup([{ name: 'permissions', label: 'Permissions', selected: 'broken' }])
expect(screen.getByRole('alert')).toHaveTextContent('unavailable')
expect(changed).toHaveBeenCalledWith({ __accessEditorReady: false })
expect(changed).not.toHaveBeenCalledWith({ permissions: '' })
})
})
describe('Paged permission matrix', () => {
it('accepts the live QQQ payload that omits empty application names and IDs', async () => {
vi.mocked(fetchWidgetData).mockResolvedValue({ type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{
total: 1, page: 0, pageSize: 25, apps: [{ label: 'Other' }],
rows: [{ name: 'documents', label: 'Documents', kind: 'TABLE', permissions: [{ id: 'documents.read', label: 'read' }] }],
}] })
const changed = setup([{ name: 'permissions', label: 'Direct permissions', catalogWidget: 'grantEditor', selected: [] }])
await userEvent.click(await screen.findByRole('checkbox', { name: 'documents.read' }))
expect(changed).toHaveBeenLastCalledWith({ permissions: 'documents.read' })
await userEvent.selectOptions(screen.getByLabelText('Application'), '')
await waitFor(() => expect(fetchWidgetData).toHaveBeenLastCalledWith('grantEditor', expect.objectContaining({ app: '' })))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('keeps unknown and off-page grants across bulk page edits and failed searches', async () => {
vi.mocked(fetchWidgetData).mockImplementation(async (_widget, params) => {
if (params?.search === 'offline') throw new Error('Offline')
const page = Number(params?.page ?? 0)
return { type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{ total: 1000, page, pageSize: 25, apps: [{ id: 'sales', label: 'Sales' }],
rows: Array.from({ length: 25 }, (_, i) => ({ name: 'table' + (page * 25 + i), label: 'Table ' + (page * 25 + i), kind: 'TABLE', app: 'sales', permissions: [{ id: 'table' + (page * 25 + i) + '.read', label: 'read' }] })) }] }
})
const changed = setup([{ name: 'permissions', label: 'Direct permissions', catalogWidget: 'grantEditor', selected: [{ id: 'legacy.read', label: 'legacy.read' }], inherited: { 'table999.read': ['Readers'] } }])
await screen.findByLabelText('table0.read')
expect(screen.getAllByRole('checkbox')).toHaveLength(25)
await userEvent.click(screen.getByRole('button', { name: 'Select 25 permissions on this page' }))
await userEvent.click(screen.getByRole('button', { name: 'Next' }))
await screen.findByLabelText('table25.read')
await userEvent.click(screen.getByLabelText('table25.read'))
await userEvent.click(screen.getByRole('button', { name: 'Previous' }))
await screen.findByLabelText('table0.read')
await userEvent.click(screen.getByRole('button', { name: 'Clear 25 permissions on this page' }))
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\ntable25.read' })
await userEvent.type(screen.getByLabelText('Search permissions or resources'), 'offline')
await screen.findByRole('alert')
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\ntable25.read' })
await userEvent.clear(screen.getByLabelText('Search permissions or resources'))
await userEvent.selectOptions(screen.getByLabelText('Show'), 'inherited')
expect(screen.getByText('table999.read — Readers')).toBeInTheDocument()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,104 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Structured named grants and searchable membership assignments. */
'use client'
import { useEffect, useId, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import type { WidgetComponentProps } from '@/components/widgets/widget-types'
import { fetchPossibleValues } from '@/lib/api/possible-values'
import { PermissionMatrix } from './PermissionMatrix'
import { INPUT_CLASS, ALERT_CLASS } from '@/components/ui/controls'
const optionSchema = z.object({ id: z.string(), label: z.string() })
const fieldSchema = z.object({
catalogWidget: z.string().optional(), name: z.string().min(1), label: z.string(), source: z.string().optional(),
selected: z.array(optionSchema).default([]), choices: z.array(optionSchema).optional(),
inherited: z.record(z.array(z.string())).optional(), policyPermissions: z.array(z.string()).optional(),
})
const payloadSchema = z.object({ fields: z.array(fieldSchema) })
type Field = z.infer<typeof fieldSchema>
type Option = z.infer<typeof optionSchema>
/**
* Edit one structured field while keeping selection independent of search results.
* @param props - Server metadata and the process value callback.
* @returns A checkbox list and, for assignments, a server-backed search box.
*/
function AccessField({ field, onChange }: { field: Field; onChange: (values: Record<string, unknown>) => void }) {
const [selected, setSelected] = useState<Option[]>(field.selected)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const [debounced, setDebounced] = useState('')
const id = useId()
useEffect(() => { const timer = setTimeout(() => setDebounced(search), 250); return () => clearTimeout(timer) }, [search])
const options = useQuery({
queryKey: ['access-editor-options', field.source, debounced],
queryFn: () => fetchPossibleValues(field.source!, { searchTerm: debounced, useCase: 'form' }),
enabled: Boolean(field.source),
})
useEffect(() => { onChange({ [field.name]: selected.map(option => option.id).join('\n') }) }, [selected, field.name, onChange])
const choices: Option[] = field.source
? (options.data ?? []).map(option => ({ id: String(option.id), label: option.label }))
: (field.choices ?? []).filter(option => option.label.toLowerCase().includes(search.toLowerCase()))
const selectedIds = new Set(selected.map(option => option.id))
const displayed = [...selected, ...choices.filter(option => !selectedIds.has(option.id))].filter(option => Boolean(field.source) || option.label.toLowerCase().includes(search.toLowerCase()))
const currentPage = Math.min(page, Math.max(0, Math.ceil(displayed.length / 25) - 1))
return <fieldset className="space-y-3 rounded-lg border border-border p-4">
<legend className="px-2 font-semibold">{field.label}</legend>
<label htmlFor={id} className="block text-sm">Search {field.label.toLowerCase()}</label>
<input id={id} data-qqq-id={'access-search-' + field.name} className={INPUT_CLASS} value={search} onChange={event => { setSearch(event.target.value); setPage(0) }} />
{options.isFetching && field.source && <p role="status" className="text-sm">Searching…</p>}
{options.isError && <p role="alert" className={ALERT_CLASS}>Could not load choices. Your selections are preserved. Change the search to try again.</p>}
<p className="text-sm text-muted-foreground">{selected.length} selected. Review validates every change before saving.</p>
<div className="max-h-80 space-y-2 overflow-auto">
{displayed.slice(currentPage * 25, currentPage * 25 + 25).map(option => <label key={option.id} className="flex items-start gap-2 text-sm">
<input type="checkbox" data-qqq-id={'access-choice-' + field.name + '-' + option.id} checked={selectedIds.has(option.id)}
onChange={event => setSelected(current => event.target.checked ? [...current, option] : current.filter(item => item.id !== option.id))} />
<span>{option.label}</span>
</label>)}
{!options.isFetching && displayed.length === 0 && <p className="text-sm text-muted-foreground">No choices found.</p>}
</div>
{displayed.length > 25 && <div className="flex gap-4">
<button type="button" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous choices</button>
<span>Page {currentPage + 1} of {Math.ceil(displayed.length / 25)}</span>
<button type="button" disabled={(currentPage + 1) * 25 >= displayed.length} onClick={() => setPage(currentPage + 1)}>Next choices</button>
</div>}
{field.inherited && Object.keys(field.inherited).length > 0 && <details>
<summary className="cursor-pointer text-sm font-medium">Inherited access — edit the source to remove it</summary>
<ul className="mt-2 space-y-1 text-sm">{Object.entries(field.inherited).map(([permission, sources]) => <li key={permission}>{permission} — {sources.join(', ')}</li>)}</ul>
</details>}
{Boolean(field.policyPermissions?.length) && <details>
<summary className="cursor-pointer text-sm font-medium">Access from this item’s policy — use Edit Policy to change it</summary>
<ul className="mt-2 text-sm">{field.policyPermissions?.map(permission => <li key={permission}>{permission}</li>)}</ul>
</details>}
</fieldset>
}
/**
* Validate server-provided metadata before rendering editable process values.
* @param props - Standard widget payload and process callback.
* @returns The access editor or a contained error.
*/
export default function AuthentikAccessEditorWidget({ data, onWidgetData }: WidgetComponentProps<Record<string, unknown>>) {
const payload = payloadSchema.safeParse(data)
const ready = payload.success
useEffect(() => { onWidgetData?.({ __accessEditorReady: ready }) }, [ready, onWidgetData])
if (!payload.success || !onWidgetData) return <p role="alert" className={ALERT_CLASS}>The access editor is unavailable. Reopen the action before continuing.</p>
return <div className="space-y-4">{payload.data.fields.map(field => field.catalogWidget ? <PermissionMatrix key={field.name} field={field} onChange={onWidgetData} /> : <AccessField key={field.name} field={field} onChange={onWidgetData} />)}</div>
}
@@ -17,6 +17,8 @@
// Tests for the AUTHENTIK_POLICY_EDITOR widget: matrix, select-all, search, unknowns, JSON output
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fetchWidgetData } from '@/lib/api/widgets'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe } from 'jest-axe'
@@ -27,6 +29,8 @@ import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
import { AuthentikPolicyEditorWidget, isPolicyPermission } from './AuthentikPolicyEditorWidget'
import type { PolicyEditorPayload } from './AuthentikPolicyEditorWidget'
vi.mock('@/lib/api/widgets', () => ({ fetchWidgetData: vi.fn() }))
const widget = { name: 'authentikPolicyEditor', type: 'AUTHENTIK_POLICY_EDITOR', label: 'Policy' } as unknown as QWidgetMetaData
const payload = (overrides: Partial<PolicyEditorPayload> = {}): PolicyEditorPayload => ({
@@ -74,7 +78,7 @@ const documentFor = (actions: string[], resources = ['person']) => JSON.stringif
*/
function setup(data: PolicyEditorPayload) {
const onWidgetData = vi.fn()
const view = render(<AuthentikPolicyEditorWidget widgetMetaData={widget} data={data as unknown as Record<string, unknown>} onWidgetData={onWidgetData} />)
const view = render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}><AuthentikPolicyEditorWidget widgetMetaData={widget} data={data as unknown as Record<string, unknown>} onWidgetData={onWidgetData} /></QueryClientProvider>)
const last = () => String(onWidgetData.mock.calls.at(-1)?.[0]?.policyDocument ?? '')
return { onWidgetData, last, ...view }
}
@@ -375,3 +379,41 @@ describe('AuthentikPolicyEditorWidget', () => {
expect(isPolicyPermission('a.b.read')).toBe(false)
})
})
describe('Large policy catalogs', () => {
it('bounds rendering and preserves off-page grants when selecting one page', async () => {
const { last } = setup(payload({ catalog: [{ app: 'large', label: 'Large', resources: Array.from({ length: 1000 }, (_, i) => ({ name: 'resource' + i, label: 'Resource ' + i, kind: 'TABLE', actions: ['read'] })) }] }))
expect(screen.getByLabelText('Read: Resource 0')).toBeInTheDocument()
expect(screen.queryByLabelText('Read: Resource 25')).not.toBeInTheDocument()
await userEvent.click(screen.getByLabelText('Everything in Large (this page)'))
await userEvent.click(screen.getByRole('button', { name: 'Next resources' }))
expect(screen.queryByLabelText('Read: Resource 0')).not.toBeInTheDocument()
await userEvent.click(screen.getByLabelText('Read: Resource 25'))
const doc = JSON.parse(last())
const resources = doc.Statement.flatMap((statement: { Resource: string[] }) => statement.Resource)
expect(resources).toHaveLength(26)
expect(resources).toContain('resource0')
expect(resources).toContain('resource25')
await userEvent.type(screen.getByLabelText('Find a resource'), 'Resource 999')
expect(screen.getByLabelText('Read: Resource 999')).toBeInTheDocument()
expect(JSON.parse(last())).toEqual(doc)
})
})
describe('Server-paged policies', () => {
it('keeps controlled selection synchronized with unknown removal and clear all', async () => {
vi.mocked(fetchWidgetData).mockResolvedValue({ type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{ total: 1, page: 0, pageSize: 25, apps: [], rows: [{ name: 'cms_page', label: 'Pages', kind: 'TABLE', app: '', permissions: [{ id: 'cms_page.read', label: 'read' }, { id: 'cms_page.edit', label: 'edit' }] }] }] })
const { last } = setup(payload({ catalog: [], catalogWidget: 'pagedPolicy', unknownPermissions: ['legacy.read'], document: documentFor(['read'], ['legacy']) }))
await userEvent.click(await screen.findByLabelText('cms_page.read'))
expect(last()).toContain('legacy')
await userEvent.click(screen.getByRole('button', { name: 'Remove legacy.read' }))
await userEvent.click(screen.getByLabelText('cms_page.edit'))
expect(last()).not.toContain('legacy')
await userEvent.click(screen.getByRole('button', { name: 'Clear all' }))
expect(screen.getByLabelText('cms_page.read')).not.toBeChecked()
expect(screen.getByLabelText('cms_page.edit')).not.toBeChecked()
expect(JSON.parse(last()).Statement).toEqual([])
})
})
@@ -29,6 +29,7 @@ import React, { lazy, Suspense, useEffect, useId, useMemo, useRef, useState } fr
import type { WidgetComponentProps } from '@/components/widgets/widget-types'
import { asList, isPlainObject } from '@/components/widgets/widget-types'
import { ALERT_CLASS, INPUT_CLASS, WARNING_CLASS, buttonClass } from '@/components/ui/controls'
import { PermissionMatrix } from './PermissionMatrix'
import { cn } from '@/lib/utils/cn'
import type { PolicyAction, PolicyFormat } from './policy-document'
import { ACTION_LABELS, FULL_ACCESS_PERMISSION, POLICY_ACTIONS, generatePolicy, parsePolicy, permissionName, roundTrips, splitPermission } from './policy-document'
@@ -65,6 +66,10 @@ export interface PolicyEditorPayload {
/** The group the policy belongs to (display only). */
groupLabel?: string
catalog: PolicyCatalogGroup[]
/** Server-paged catalog source; older hosts send the full catalog. */
catalogWidget?: string
/** Unknown grants identified against the complete server catalog. */
unknownPermissions?: string[]
/** The group's current document (JSON text), or empty for none. */
document?: string
/** Whether the current document grants full access; older servers omit this. */
@@ -172,11 +177,12 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
const [selected, setSelected] = useState<Set<string>>(() => new Set(initial.permissions))
const fullAccess = selected.has(FULL_ACCESS_PERMISSION)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const [memberUsername, setMemberUsername] = useState('')
const member = payload.members?.find((candidate) => candidate.username === memberUsername)
const generated = useMemo(() => generatePolicy(selected, format), [format, selected])
const unknown = useMemo(() => fullAccess ? [] : [...selected].filter((permission) => !known.has(permission)).sort(), [fullAccess, known, selected])
const unknown = useMemo(() => fullAccess ? [] : [...selected].filter((permission) => payload.catalogWidget ? payload.unknownPermissions?.includes(permission) : !known.has(permission)).sort(), [fullAccess, known, selected, payload.catalogWidget, payload.unknownPermissions])
const valid = useMemo(() => roundTrips(selected, format), [format, selected])
useEffect(() => {
@@ -194,6 +200,16 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
}))
.filter((group) => group.resources.length > 0), [catalog, term])
const matchingCount = visibleGroups.reduce((sum, group) => sum + group.resources.length, 0)
const currentPage = Math.min(page, Math.max(0, Math.ceil(matchingCount / 25) - 1))
const pageResources = visibleGroups.flatMap((group) => group.resources.map((resource) => ({ group, resource })))
.slice(currentPage * 25, (currentPage + 1) * 25)
const pageGroups = visibleGroups.map((group) => ({
...group,
resources: pageResources.filter((entry) => entry.group === group).map((entry) => entry.resource),
label: matchingCount > 25 ? group.label + ' (this page)' : group.label,
})).filter((group) => group.resources.length > 0)
const set = (permissions: string[], on: boolean) => {
setSelected((current) => {
const next = new Set(current)
@@ -235,7 +251,7 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
<fieldset disabled={fullAccess} aria-disabled={fullAccess} hidden={fullAccess} className="min-w-0">
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-end @xl:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div hidden={Boolean(payload.catalogWidget)} className="flex min-w-0 flex-1 flex-col gap-1">
<label htmlFor={searchId} className="text-sm font-medium text-foreground">Find a resource</label>
<input
id={searchId}
@@ -244,7 +260,7 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
placeholder="App, table, process or report"
value={search}
aria-disabled={fullAccess}
onChange={(event) => setSearch(event.target.value)}
onChange={(event) => { setSearch(event.target.value); setPage(0) }}
data-qqq-id="policy-editor-search"
/>
</div>
@@ -262,11 +278,18 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
{fullAccess && <p className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">Individual resource selection is disabled while full access is on.</p>}
<div hidden={fullAccess}>
<div className="flex min-w-0 flex-col gap-3">
{payload.catalogWidget ? <PermissionMatrix field={{ name: 'policyPermissions', label: 'Policy permissions', catalogWidget: payload.catalogWidget, selected: [] }} selection={selected} onSelectionChange={setSelected} /> : <>
{resourceCount === 0 && <p className="text-sm text-muted-foreground">This app has no resources that a policy can grant.</p>}
{resourceCount > 0 && visibleGroups.length === 0 && <p className="text-sm text-muted-foreground" role="status">No resource matches the search.</p>}
{visibleGroups.map((group) => (
{matchingCount > 25 && <div className="flex items-center gap-3 text-sm">
<button type="button" className={buttonClass('secondary', 'sm')} disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous resources</button>
<span role="status">{matchingCount} resources · Page {currentPage + 1} of {Math.ceil(matchingCount / 25)}. Bulk controls affect only this page.</span>
<button type="button" className={buttonClass('secondary', 'sm')} disabled={(currentPage + 1) * 25 >= matchingCount} onClick={() => setPage(currentPage + 1)}>Next resources</button>
</div>}
{pageGroups.map((group) => (
<PolicyGroupSection key={group.app ?? '(other)'} group={group} selected={selected} onSet={set} />
))}
</>}
</div>
</div>
</fieldset>
@@ -0,0 +1,121 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Bounded permission catalog and grant source views. */
'use client'
import { useEffect, useId, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { fetchWidgetData } from '@/lib/api/widgets'
import { INPUT_CLASS, ALERT_CLASS } from '@/components/ui/controls'
const optionSchema = z.object({ id: z.string(), label: z.string() })
// QQQ omits empty strings on the wire. Only application membership uses the
// empty string as a valid identity (the Other/unassigned application bucket).
const appOptionSchema = optionSchema.extend({ id: z.string().default('') })
const pageSchema = z.object({ fields: z.array(z.object({
rows: z.array(z.object({ name: z.string(), label: z.string(), kind: z.string(), app: z.string().default(''), permissions: z.array(optionSchema) })),
total: z.number().int().nonnegative(), page: z.number().int().nonnegative(), pageSize: z.number().int().positive(), apps: z.array(appOptionSchema),
})).min(1) })
type Option = z.infer<typeof optionSchema>
interface Field {
name: string; label: string; catalogWidget?: string; selected: Option[]
inherited?: Record<string, string[]>; policyPermissions?: string[]
}
/**
* Paged resource matrix. Selection is independent of catalog pages and requests.
* @param props - Initial grants, catalog source, and process output callback.
* @returns A searchable matrix with page-scoped bulk actions.
*/
export function PermissionMatrix({ field, onChange, selection, onSelectionChange }: {
field: Field; onChange?: (values: Record<string, unknown>) => void
selection?: Set<string>; onSelectionChange?: (selection: Set<string>) => void
}) {
const [localSelected, setSelected] = useState(() => new Map(field.selected.map(option => [option.id, option])))
const selected = useMemo(() => selection ? new Map([...selection].map(id => [id, { id, label: id }])) : localSelected, [selection, localSelected])
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [app, setApp] = useState('*')
const [kind, setKind] = useState('*')
const [page, setPage] = useState(0)
const [view, setView] = useState('catalog')
const id = useId()
useEffect(() => { const timer = setTimeout(() => { setDebounced(search); setPage(0) }, 250); return () => clearTimeout(timer) }, [search])
useEffect(() => { if (!selection) onChange?.({ [field.name]: [...selected.keys()].join('\n') }) }, [selected, field.name, onChange, selection])
const catalog = useQuery({
queryKey: ['access-catalog', field.catalogWidget, debounced, app, kind, page],
queryFn: async () => pageSchema.parse(await fetchWidgetData(field.catalogWidget!, { accessCatalog: true, search: debounced, app, kind, page })).fields[0],
enabled: view === 'catalog',
})
const data = catalog.data
const busy = catalog.isFetching || search !== debounced
const visible = data?.rows.flatMap(row => row.permissions) ?? []
const actions = [...new Set(visible.map(option => option.label))]
const change = (options: Option[], checked: boolean) => {
const next = new Map(selected)
options.forEach(option => { if (checked) next.set(option.id, option); else next.delete(option.id) })
if (selection) onSelectionChange?.(new Set(next.keys()))
else setSelected(next)
}
const localEntries = (view === 'selected' ? [...selected.values()].map(option => [option.id, 'Direct grant'] as const)
: [...Object.entries(field.inherited ?? {}).map(([permission, sources]) => [permission, sources.join(', ')] as const),
...(field.policyPermissions ?? []).map(permission => [permission, 'This item’s policy'] as const)])
.filter(([permission, source]) => (permission + ' ' + source).toLowerCase().includes(search.toLowerCase()))
const localPage = Math.min(page, Math.max(0, Math.ceil(localEntries.length / 25) - 1))
const currentPage = view === 'catalog' ? (data?.page ?? 0) : localPage
const total = view === 'catalog' ? (data?.total ?? 0) : localEntries.length
return <fieldset className="space-y-3 rounded-lg border border-border p-4" data-qqq-id="access-permission-matrix">
<legend className="px-2 font-semibold">{field.label}</legend>
<p className="text-sm">{selected.size} direct permissions selected. Changes affect named permissions on existing resources. Future resources are not included.</p>
<label htmlFor={id + '-view'}>Show</label>
<select id={id + '-view'} className={INPUT_CLASS} value={view} onChange={event => { setView(event.target.value); setPage(0) }}>
<option value="catalog">Permission catalog</option><option value="selected">Selected direct grants</option><option value="inherited">Why access is granted</option>
</select>
<label htmlFor={id + '-search'}>Search permissions or resources</label>
<input id={id + '-search'} className={INPUT_CLASS} value={search} onChange={event => setSearch(event.target.value)} />
{view === 'catalog' && <>
<label htmlFor={id + '-app'}>Application</label>
<select id={id + '-app'} className={INPUT_CLASS} value={app} onChange={event => { setApp(event.target.value); setPage(0) }}>
<option value="*">All applications</option>{data?.apps.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
<label htmlFor={id + '-kind'}>Resource type</label>
<select id={id + '-kind'} className={INPUT_CLASS} value={kind} onChange={event => { setKind(event.target.value); setPage(0) }}>
<option value="*">All resource types</option>{['APP', 'TABLE', 'PROCESS', 'REPORT', 'WIDGET', 'OTHER'].map(value => <option key={value}>{value}</option>)}
</select>
{busy && <p role="status">Loading permission page…</p>}
{catalog.isError && <p role="alert" className={ALERT_CLASS}>Could not load permissions. Your selections are preserved. <button type="button" onClick={() => void catalog.refetch()}>Retry</button></p>}
<div className="flex gap-4">
<button type="button" disabled={busy || !visible.length || catalog.isError} onClick={() => change(visible, true)}>Select {visible.length} permissions on this page</button>
<button type="button" disabled={busy || !visible.length || catalog.isError} onClick={() => change(visible, false)}>Clear {visible.length} permissions on this page</button>
</div>
<div className="overflow-auto"><table className="w-full text-sm"><caption className="sr-only">Direct permissions by resource and action</caption>
<thead><tr><th scope="col" className="text-left">Resource</th>{actions.map(action => <th scope="col" key={action}>{action === 'insert' ? 'Create' : action === 'hasAccess' ? 'Access' : action}</th>)}</tr></thead>
<tbody>{data?.rows.map(row => <tr key={row.name}><th scope="row" className="text-left"><span>{row.label}</span><small className="block font-normal">{row.name} · {row.kind}</small></th>
{actions.map(action => { const option = row.permissions.find(item => item.label === action); return <td key={action} className="p-2 text-center">{option ? <input type="checkbox" aria-label={option.id} data-qqq-id={'access-choice-permissions-' + option.id} checked={selected.has(option.id)} disabled={busy} onChange={event => change([option], event.target.checked)} /> : '—'}</td> })}
</tr>)}</tbody>
</table></div>
</>}
{view !== 'catalog' && <ul className="space-y-2">{localEntries.slice(localPage * 25, localPage * 25 + 25).map(([permission, source]) => <li key={permission + source}>
{view === 'selected' ? <label><input type="checkbox" checked aria-label={permission} onChange={() => change([{ id: permission, label: permission }], false)} /> {permission}</label> : <span>{permission} — {source}</span>}
</li>)}</ul>}
<p role="status">{total} {view === 'catalog' ? 'resources' : 'permissions'} · Page {currentPage + 1} of {Math.max(1, Math.ceil(total / 25))}</p>
<div className="flex gap-4"><button type="button" disabled={currentPage === 0 || (view === 'catalog' && busy)} onClick={() => setPage(currentPage - 1)}>Previous</button>
<button type="button" disabled={(currentPage + 1) * 25 >= total || (view === 'catalog' && busy)} onClick={() => setPage(currentPage + 1)}>Next</button></div>
{view === 'inherited' && <p className="text-sm">Inherited grants must be changed at their source. Policy grants use Edit Policy.</p>}
</fieldset>
}
+1 -1
View File
@@ -87,7 +87,7 @@ function NamedWidget({ widget, seeded, index }: { widget: QWidgetMetaData; seede
produced.current = type === 'childRecordList' ? { frontendRecords: frontendRecordsJson(data) } : { ...(produced.current ?? {}), ...data }
}, [type])
useSubmitContributor(`widget-${index}-${widget.name}`, () => ({ maySubmit: true, values: produced.current ?? undefined }))
useSubmitContributor(`widget-${index}-${widget.name}`, () => ({ maySubmit: type !== 'AUTHENTIK_ACCESS_EDITOR' || produced.current?.__accessEditorReady === true, values: produced.current ?? undefined }))
return (
<div data-qqq-id={`process-widget-${widget.name}`}>
@@ -78,6 +78,7 @@ const PieChartWidget = lazy(() => import('./PieChartWidget').then((m) => ({ defa
const FilterAndColumnsSetupWidget = lazy(() => import('./FilterAndColumnsSetupWidget').then((m) => ({ default: m.FilterAndColumnsSetupWidget })))
const PivotTableSetupWidget = lazy(() => import('./PivotTableSetupWidget').then((m) => ({ default: m.PivotTableSetupWidget })))
const SqlWorkbenchWidget = lazy(() => import('@/components/sql-workbench/SqlWorkbenchWidget'))
const AuthentikAccessEditorWidget = lazy(() => import('@/components/authentik/AuthentikAccessEditorWidget'))
const AuthentikPolicyEditorWidget = lazy(() => import('@/components/authentik/AuthentikPolicyEditorWidget'))
const RowBuilderWidget = lazy(() => import('./RowBuilderWidget').then((m) => ({ default: m.RowBuilderWidget })))
@@ -219,6 +220,8 @@ export function WidgetRenderer({ widgetMetaData, data, recordContext, formContex
return <EsbOverviewWidget widgetMetaData={widgetMetaData} />
case 'SQL_WORKBENCH':
return <Suspense fallback={SetupFallback}><SqlWorkbenchWidget widgetMetaData={widgetMetaData} /></Suspense>
case 'AUTHENTIK_ACCESS_EDITOR':
return <Suspense fallback={SetupFallback}><AuthentikAccessEditorWidget {...common} data={data} /></Suspense>
case 'AUTHENTIK_POLICY_EDITOR':
return <Suspense fallback={SetupFallback}><AuthentikPolicyEditorWidget {...common} data={data} /></Suspense>
case 'recordGrid':
+2
View File
@@ -5,6 +5,8 @@ import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
test: {
// Keep DOM suites within a predictable memory/CPU budget on shared build hosts.
maxWorkers: 4,
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],