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:
9 files changed
+434
-6
No files matched your search
@@ -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>
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user