fix(settings): audit log scrolls in place instead of growing the settings page (#735)

* fix(settings): audit log scrolls in place instead of growing the settings page

The rows and Show more sit in a keyboard-focusable region capped at min(24rem, 60vh); the header, export and filters stay above it.

* style: oxfmt

* fix(settings): add the audit scroll label to en.json; move the scroll region to its own file (max-lines)
This commit is contained in:
Neil
2026-11-13 15:44:11 -08:00
committed by GitHub
parent 613d0d42c2
commit 69aad2aeaa
4 changed files with 103 additions and 47 deletions
@@ -0,0 +1,28 @@
import type React from 'react'
/**
* A long audit history scrolls in place so the settings page keeps its length: the header, export
* and filters stay above it, and Show more pages in at the bottom. About ten rows (384px), or 60%
* of the window when that is smaller. The top edge is ruled so more reads as more, and it runs to
* the card's edges so the scrollbar sits against the card border. Focusable so the keyboard can
* scroll it.
*/
export function AuditLogScrollRegion({
label,
children
}: {
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div
data-audit-log-scroll=""
className="-mx-5 max-h-[min(24rem,60vh)] scrollbar-sleek overflow-y-auto overscroll-contain border-t border-border px-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
tabIndex={0}
role="region"
aria-label={label}
>
{children}
</div>
)
}
@@ -2,6 +2,7 @@
// category and by person, with CSV export. Owners and admins only. A team whose plan does not
// include it sees the upsell instead; the control plane refuses the data either way.
import { Download, History, Loader2 } from 'lucide-react'
import { cn } from '@/lib/class-names'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import {
@@ -26,6 +27,7 @@ import {
CloudApiSkeletonRows
} from '../cloud-api/cloud-api-parts'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { AuditLogScrollRegion } from './AuditLogScrollRegion'
import { EnterpriseUpsell, EnterpriseUpsellLine, type EnterpriseContact } from './EnterpriseUpsell'
import { auditCategoryLabel, auditEventSentence, unnamedPersonIn } from './enterprise-audit-copy'
import { useAppStore } from '@/store'
@@ -364,55 +366,60 @@ export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.El
/>
</div>
) : (
<ul
aria-label={t('settings.enterprise.audit.title', 'Audit log')}
aria-busy={log.refreshing}
className={log.refreshing ? 'opacity-60' : undefined}
<AuditLogScrollRegion
label={t('settings.enterprise.audit.scrollLabel', 'Audit log entries')}
>
{log.events.map((event) => (
<li
key={event.id}
data-settings-row=""
className="flex items-center gap-3 py-2 text-xs"
>
<AuditSentence sentence={auditEventSentence(event, t, locale, nameOf)} />
<span className="w-28 shrink-0 truncate text-muted-foreground">
{auditCategoryLabel(event.category, t)}
</span>
<span className="w-48 shrink-0 truncate text-foreground">
<ActorCell event={event} viewerUserId={props.viewerUserId} />
</span>
<span
className="w-24 shrink-0 text-right text-muted-foreground"
title={formatAbsoluteTime(event.at, locale)}
>
{formatRelativeFromNow(event.at, props.now, locale)}
</span>
</li>
))}
</ul>
)}
{log.moreError ? (
<div data-settings-row="" className="py-3">
<CloudApiErrorNotice message={log.moreError} onRetry={props.onLoadMore} />
</div>
) : log.hasMore ? (
<div data-settings-row="" className="flex justify-center py-3">
<Button
type="button"
variant="ghost"
size="sm"
aria-disabled={log.loadingMore}
aria-busy={log.loadingMore}
onClick={() => (log.loadingMore ? undefined : props.onLoadMore())}
<ul
aria-label={t('settings.enterprise.audit.title', 'Audit log')}
aria-busy={log.refreshing}
// The region draws the top rule; the first row's own rule would double it.
className={cn('-mt-px', log.refreshing && 'opacity-60')}
>
{log.loadingMore ? (
<Loader2 className="animate-spin motion-reduce:animate-none" />
) : null}
{t('settings.enterprise.audit.showMore', 'Show more')}
</Button>
</div>
) : null}
{log.events.map((event) => (
<li
key={event.id}
data-settings-row=""
className="flex items-center gap-3 py-2 text-xs"
>
<AuditSentence sentence={auditEventSentence(event, t, locale, nameOf)} />
<span className="w-28 shrink-0 truncate text-muted-foreground">
{auditCategoryLabel(event.category, t)}
</span>
<span className="w-48 shrink-0 truncate text-foreground">
<ActorCell event={event} viewerUserId={props.viewerUserId} />
</span>
<span
className="w-24 shrink-0 text-right text-muted-foreground"
title={formatAbsoluteTime(event.at, locale)}
>
{formatRelativeFromNow(event.at, props.now, locale)}
</span>
</li>
))}
</ul>
{log.moreError ? (
<div data-settings-row="" className="py-3">
<CloudApiErrorNotice message={log.moreError} onRetry={props.onLoadMore} />
</div>
) : log.hasMore ? (
<div data-settings-row="" className="flex justify-center py-3">
<Button
type="button"
variant="ghost"
size="sm"
aria-disabled={log.loadingMore}
aria-busy={log.loadingMore}
onClick={() => (log.loadingMore ? undefined : props.onLoadMore())}
>
{log.loadingMore ? (
<Loader2 className="animate-spin motion-reduce:animate-none" />
) : null}
{t('settings.enterprise.audit.showMore', 'Show more')}
</Button>
</div>
) : null}
</AuditLogScrollRegion>
)}
</>
)}
</SettingsGroup>
@@ -170,6 +170,26 @@ describe('audit log', () => {
expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull()
})
it('scrolls a long log in place: rows and Show more inside, header, export and filters outside', () => {
render(
<EnterpriseSettingsView
audit={fixtureAuditProps({ log: { ...EMPTY, events: FIXTURE_EVENTS, hasMore: true } })}
/>
)
const region = screen.getByRole('region', { name: 'Audit log entries' })
// Capped so the settings page keeps a normal length, and reachable by keyboard to scroll.
expect(region.className).toContain('max-h-[min(24rem,60vh)]')
expect(region.className).toContain('overflow-y-auto')
expect(region.tabIndex).toBe(0)
expect(region.querySelector('ul[aria-label="Audit log"]')).toBeTruthy()
expect(region.contains(screen.getByRole('button', { name: 'Show more' }))).toBe(true)
expect(region.contains(screen.getByRole('button', { name: 'Export CSV' }))).toBe(false)
expect(region.contains(screen.getByRole('combobox', { name: 'Filter by activity' }))).toBe(
false
)
expect(region.contains(screen.getByText('Audit log', { selector: 'h3' }))).toBe(false)
})
it('offers both filters and the export, and exports once per click', () => {
const onExport = vi.fn()
const { rerender } = render(<EnterpriseSettingsView audit={fixtureAuditProps({ onExport })} />)
+1
View File
@@ -1507,6 +1507,7 @@
"emptyBody": "Changes to members, API keys, secrets, workspaces and settings will show up here.",
"clearFilters": "Clear filters",
"showMore": "Show more",
"scrollLabel": "Audit log entries",
"category": {
"members": "Members",
"security": "Security",